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, t } from "elysia"; import { PrintfulClient, PrintfulError, WebflowClient, WebflowError, Printful, Webflow, } from "@blade-and-brawn/commerce"; import zipcodesUs from "zipcodes-us"; import { env, log } from "./util"; import serverTiming from "@elysia/server-timing"; import jwt from "@elysia/jwt"; import { SyncService } from "./services/sync"; import cluster from "node:cluster"; // 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 syncService = new SyncService(printful, webflow); // ELYSIA // ----------- export const app = new Elysia() .use(serverTiming()) .use( cors({ origin: [ // production /^https?:\/\/([a-z0-9-]+\.)?bladeandbrawn\.com$/i, // testing /^https?:\/\/([a-z0-9-]+\.)?bladeandbrawn\.webflow\.io$/i, // development "http://localhost:5173", ], }), ).use(jwt({ name: "jwt", secret: env.AUTH_SECRET })) .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 = { "/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" })) // AUTHENTICATION .post("/auth/login", async ({ jwt, body, cookie: { auth } }) => { if (body.password !== env.ADMIN_PASSWORD) throw status(401, "Invalid credentials"); auth.set({ value: await jwt.sign({ id: "admin", exp: "7d" }), path: "/", maxAge: 60 * 60 * 24 * 7, sameSite: "lax", httpOnly: true, secure: env.NODE_ENV === "production", domain: env.NODE_ENV === "production" ? ".bladeandbrawn.com" : undefined }); }, { body: t.Object({ password: t.String() }), cookie: t.Cookie({ auth: t.Optional(t.String()) }) }) // CALCULATOR .post("/calculate", async ({ body }) => { return { levels: levelCalculator.calculate(body.player, body.activityPerformances), }; }, { body: t.Object({ player: PlayerSchema, activityPerformances: t.Array(ActivityPerformanceSchema) }), }) // COMMERCE .group("/products", (app) => app .guard({ cookie: t.Cookie({ auth: t.Optional(t.String()) }) }) .onBeforeHandle(async ({ jwt, cookie: { auth } }) => { const token = auth.value && await jwt.verify(auth.value); if (!token) throw status(401, "Unauthorized"); }) .group("/sync", (app) => app // Sync status .get("/", async ({ }) => { const activeSync = await syncService.getActiveSync(); if (!activeSync) throw new NotFoundError("No sync in progress"); return { startDate: activeSync.started_at, syncingPrintfulProductIds: activeSync.syncing_printful_product_ids } }) // Run sync .post("/:printfulProductId?", async ({ params: { printfulProductId } }) => { const printfulProductIds = printfulProductId ? [printfulProductId] : null; log.debug({ printfulProductIds }); await syncService.sync({ filter: { printfulProductIds } }); }, { params: t.Object({ printfulProductId: t.Optional(t.Numeric()) }) }), ) .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: t.Object({ printfulProductId: t.Numeric() }) }) ) // 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 syncService.sync({ filter: { printfulProductIds: [printfulProduct.id] } }); break; } case Printful.Webhook.Event.ProductDeleted: { const printfulProduct = payload.data.sync_product; const webflowProductId = printfulProduct.external_id.split("-")[0]; log.info({ externalId: payload.data.sync_product.external_id, webflowProductId }, "printful webhook: product deleted"); if (!webflowProductId) throw new NotFoundError("Missing webflow product ID"); await webflow.Products.remove(webflowProductId); 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, async () => { if (cluster.worker?.id === 1) { log.info({ port: 3000 }, "server started") await syncService.syncNext().catch((err) => log.error({ err }, "failed to resume product sync queue")); } }); export type API = typeof app