import { ActivityPerformanceSchema, PlayerSchema, } from "@blade-and-brawn/domain" import { cors } from "@elysiajs/cors"; import { Elysia, NotFoundError, status, t } from "elysia"; import { PrintfulError, WebflowError, Printful, Webflow, } from "@blade-and-brawn/commerce"; import zipcodesUs from "zipcodes-us"; import { DEFAULT_NAME, env, log } from "./util"; import serverTiming from "@elysia/server-timing"; import jwt from "@elysia/jwt"; import { CommerceService } from "./services/commerce"; import cluster from "node:cluster"; import { randomUUIDv7 } from "bun"; import { CalculatorService, CalculatorUnavailableError } from "./services/calculator"; import { StandardsParamsSchema } from "@blade-and-brawn/calculator"; import { StandardsService } from "./services/standards"; // SERVICES // ----------- const s = (() => { const Standards = new StandardsService(); const Calculator = new CalculatorService(DEFAULT_NAME); const Commerce = new CommerceService(); return { Standards, Calculator, Commerce }; })(); // 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, CalculatorUnavailableError }) .onError(({ code, error }) => { switch (code) { case "PrintfulError": case "WebflowError": log.error( { upstreamStatus: error.upstreamStatus, payload: error.payload }, error.message, ); return status(error.status, { error: error.message }); case "CalculatorUnavailableError": log.error({ err: error.cause }, error.message); return status(error.status, { error: error.message }); case "NOT_FOUND": return status(error.status, { error: error.message }); case "VALIDATION": return status(error.status, { 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"); }) .guard({ cookie: t.Cookie({ auth: t.Optional(t.String()) }) }) .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({ sessionId: randomUUIDv7(), 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() }) }) // CALCULATOR .group("/calculator", (app) => app // Non-authenticated .post("/calculate", async ({ body }) => { return { levels: await s.Calculator.calculate(body.player, body.activityPerformances) }; }, { body: t.Object({ player: PlayerSchema, activityPerformances: t.Array(ActivityPerformanceSchema) }), }) .resolve(async ({ jwt, cookie: { auth } }) => { const token = auth.value && await jwt.verify(auth.value); if (!token || !token.sessionId) throw status(401, "Unauthorized"); return { sessionId: token.sessionId.toString() }; }) .get("/standards/config", async () => { return await s.Calculator.Standards.Config.get(); }) .post("/standards/config/switch", async ({ body: { standardsConfigId: id } }) => { await s.Calculator.Standards.Config.switch(id); }, { body: t.Object({ standardsConfigId: t.String() }) }) ) .group("/standards", (app) => app .resolve(async ({ jwt, cookie: { auth } }) => { const token = auth.value && await jwt.verify(auth.value); if (!token || !token.sessionId) throw status(401, "Unauthorized"); return { sessionId: token.sessionId.toString() }; }) .post("/configs", async ({ body: { name, datasetId, params } }) => { return await s.Standards.Configs.create(name, datasetId, params); }, { body: t.Object({ name: t.String(), datasetId: t.String(), params: StandardsParamsSchema }) }) .get("/configs", async () => { return await s.Standards.Configs.list(); }) .get("/configs/:id", async ({ params: { id } }) => { return await s.Standards.Configs.get(id); }, { params: t.Object({ id: t.String() }) }) .put("/configs/:id", async ({ params: { id }, body: { name, datasetId, params: parameters } }) => { await s.Standards.Configs.update(id, name, datasetId, parameters); }, { params: t.Object({ id: t.String() }), body: t.Object({ name: t.String(), datasetId: t.String(), params: StandardsParamsSchema }) }) .get("/datasets", async () => { return await s.Standards.Datasets.list(); }) .get("/datasets/:id", async ({ params: { id } }) => { return await s.Standards.Datasets.get(id); }, { params: t.Object({ id: t.String() }) }) ) // COMMERCE .group("/products", (app) => app .resolve(async ({ jwt, cookie: { auth } }) => { const token = auth.value && await jwt.verify(auth.value); if (!token || !token.sessionId) throw status(401, "Unauthorized"); return { sessionId: token.sessionId.toString() }; }) .group("/sync", (app) => app // Sync status .get("/", async ({ sessionId }) => { const latestSync = await s.Commerce.Sync.getLatest(sessionId); if (!latestSync) throw new NotFoundError("No sync found"); return { startDate: latestSync.started_at, status: s.Commerce.Sync.deriveStatus(latestSync), syncingPProductIds: latestSync.syncing_p_product_ids } }) // Run sync .post("/:pProductId?", async ({ params: { pProductId }, sessionId }) => { await s.Commerce.Sync.start({ session: { id: sessionId, name: "Portal" }, filter: { pProductIds: pProductId ? [pProductId] : null } }); }, { params: t.Object({ pProductId: t.Optional(t.Numeric()) }) }), ) .get("/:pProductId", async ({ params: { pProductId } }) => { const pProduct = await s.Commerce.Printful.Products.get(pProductId); if (!pProduct) throw new NotFoundError("Missing printful product"); const wProductId = pProduct.sync_product.external_id.split("-")[0]; if (!wProductId) throw new NotFoundError("Missing webflow product ID"); const wProduct = await s.Commerce.Webflow.Products.get(wProductId); return { pProduct, wProduct }; }, { params: t.Object({ pProductId: 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 pProduct = payload.data.sync_product; log.info({ productId: pProduct.id }, "printful webhook: product updated"); await s.Commerce.Sync.start({ session: { id: randomUUIDv7(), name: "Printful" }, filter: { pProductIds: [pProduct.id] } }); break; } case Printful.Webhook.Event.ProductDeleted: { const pProduct = payload.data.sync_product; const wProductId = pProduct.external_id.split("-")[0]; log.info({ externalId: payload.data.sync_product.external_id, wProductId }, "printful webhook: product deleted"); if (!wProductId) throw new NotFoundError("Missing webflow product ID"); await s.Commerce.Webflow.Products.remove(wProductId); break; } case Printful.Webhook.Event.PackageShipped: { const wOrderId = payload.data.order.external_id; const shipInfo = payload.data.shipment; log.info({ wOrderId, carrier: shipInfo.carrier, tracking: shipInfo.tracking_number }, "printful webhook: package shipped"); await s.Commerce.Webflow.Orders.update(wOrderId, { shippingTrackingURL: shipInfo.tracking_url, shippingTracking: shipInfo.tracking_number, shippingProvider: shipInfo.carrier, }); await s.Commerce.Webflow.Orders.fulfill(wOrderId, { sendOrderFulfilledEmail: true, }); break; } default: log.warn({ type: (payload as any).type }, "printful webhook: unhandled event type"); } }) .post("/webhook/webflow", async ({ request, body }) => { if (!s.Commerce.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 wOrder = payload.payload; log.info({ orderId: wOrder.orderId }, "webflow webhook: order created"); await s.Commerce.Printful.Orders.create({ external_id: wOrder.orderId, // TODO: derive from webflow shipping: "STANDARD", recipient: { name: wOrder.shippingAddress.addressee, address1: wOrder.shippingAddress.line1, address2: wOrder.shippingAddress.line2, city: wOrder.shippingAddress.city, state_code: zipcodesUs.find( wOrder.shippingAddress.postalCode.split("-")[0] ?? "", ).stateCode, country_code: wOrder.shippingAddress.country, zip: wOrder.shippingAddress.postalCode, }, items: wOrder.purchasedItems.map((wOrderSku) => ({ external_variant_id: wOrderSku.variantId, quantity: wOrderSku.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") try { const frontQueuedSync = await s.Commerce.Sync.Queue.front(); if (frontQueuedSync) await s.Commerce.Sync.start(frontQueuedSync); } catch (err) { log.error({ err }, "failed to resume product sync queue") } } }); export type API = typeof app