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"; const log = pino({ level: env.LOG_LEVEL }); // 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() .derive(() => ({ startTime: Date.now(), requestId: crypto.randomUUID(), })) .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", ], }), ) .error({ PrintfulError, WebflowError, }) .onError(({ code, error, startTime, requestId }) => { const duration = Date.now() - (startTime ?? Date.now()); switch (code) { case "PrintfulError": case "WebflowError": log.error( { requestId, duration, upstreamStatus: error.status, payload: error.payload }, error.message, ); return status(502, { error: error.message }); default: log.error({ requestId, duration, err: error }, "unhandled error"); } }) .onAfterHandle(({ request, set, startTime, requestId }) => { log.info({ requestId, method: request.method, path: new URL(request.url).pathname, status: set.status ?? 200, duration: Date.now() - startTime, }, "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) }), // afterResponse: ({ query, body, responseValue }) => { // if (query.log === "true") { // void fetch("https://kv-logger.xominus.workers.dev", { // method: "POST", // headers: { "content-type": "application/json" }, // body: JSON.stringify({ // output: responseValue, // player: body.player, // activityPerformances: body.activityPerformances // }), // }); // } // } }) // COMMERCE // TODO: protect with api key/token .group("/products", (app) => app .group("/sync", (app) => app // Sync status .get("/", async ({ }) => await productSyncer.getState()) // Full sync .post("/", async ({ }) => { const syncState = await productSyncer.getState(); if (syncState.isSyncing) throw status(409, "Sync already in progress"); await productSyncer.sync(); }) // Per-product sync .post("/:printfulProductId", async ({ params }) => { const syncState = await productSyncer.getState(); if (syncState.isSyncing) throw status(409, "Sync already in progress"); await productSyncer.sync(params.printfulProductId); }, { 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 }) => { 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(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"));