diff --git a/apps/api/package.json b/apps/api/package.json index 20bad3c..a6e13dc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,7 +5,8 @@ "private": true, "scripts": { "dev": "bun run --hot src/index.ts", - "start": "bun run src/index.ts" + "start": "bun run src/index.ts", + "build": "bun build --compile --minify-whitespace --minify-syntax --target bun-linux-x64 --outfile dist/server src/index.ts" }, "dependencies": { "@blade-and-brawn/calculator": "workspace:*", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2d808dd..cad0896 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,227 +1,11 @@ -import { - DEFAULT_STANDARDS_DATA, - LevelCalculator, - Standards, -} from "@blade-and-brawn/calculator"; +import cluster from 'node:cluster' +import os from 'node:os' +import process from 'node:process' -import { - ActivityPerformanceSchema, - PlayerSchema, - type ActivityPerformance, - type Player, -} from "@blade-and-brawn/domain" -import { cors } from "@elysiajs/cors"; -import { Elysia, NotFoundError, status } from "elysia"; -import { - PrintfulService, - SyncService, - WebflowService, - Printful, - Webflow, - FetchError, -} from "@blade-and-brawn/commerce"; -import zipcodesUs from "zipcodes-us"; -import z from "zod"; - -const levelCalculator = new LevelCalculator( - new Standards(DEFAULT_STANDARDS_DATA), -); - -export const app = new Elysia() - .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({ - FetchError, - }) - - .onError(async ({ code, error }) => { - switch (code) { - case "FetchError": - console.error(error.message, await error.parse()); - return error; - } - }) - - .onAfterHandle(({ request, set }) => { - console.log( - JSON.stringify({ - lvl: "info", - msg: "req", - method: request.method, - path: new URL(request.url).pathname, - status: set.status ?? 200, - }), - ); - }) - - .get("/", () => "Hello world") - - // 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 ({ }) => SyncService.state) - // Full sync - .post("/", async ({ }) => { - if (SyncService.state.isSyncing) - return status(409, { error: "Sync already in progress" }); - - await SyncService.sync(); - return { ok: true }; - }) - // Per-product sync - .post("/:printfulProductId", async ({ params }) => { - if (SyncService.state.isSyncing) - return status(409, { error: "Sync already in progress" }); - - await SyncService.sync(params.printfulProductId); - return { ok: true }; - }, { params: z.object({ printfulProductId: z.number() }) }), - ) - .get("/:printfulProductId", async ({ params }) => { - const printfulProductId = +params.printfulProductId; - const printfulProduct = - await PrintfulService.Products.get(printfulProductId); - if (!printfulProduct) { - return new NotFoundError(); - } - - const webflowProductId = - printfulProduct.sync_product.external_id.split("-")[0]; - if (!webflowProductId) { - return new NotFoundError(); - } - - const webflowProduct = - await WebflowService.Products.get(webflowProductId); - - return { printfulProduct, webflowProduct }; - }), - ) - - // 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; - await SyncService.sync(printfulProduct.id); - break; - } - case Printful.Webhook.Event.ProductDeleted: { - await WebflowService.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; - - await WebflowService.Orders.update(webflowOrderId, { - shippingTrackingURL: shipInfo.tracking_url, - shippingTracking: shipInfo.tracking_number, - shippingProvider: shipInfo.carrier, - }); - await WebflowService.Orders.fulfill(webflowOrderId, { - sendOrderFulfilledEmail: true, - }); - break; - } - } - - return { ok: true }; - }) - .post("/webhook/webflow", async ({ request, body, set }) => { - if (!WebflowService.Util.verifyWebflowSignature(request, body)) { - set.status = 400; - return "Invalid signature"; - } - - const payload = body as Webflow.Webhook.EventPayload; - - switch (payload.triggerType) { - case Webflow.Webhook.Event.OrderCreated: { - const webflowOrder = payload.payload; - - await PrintfulService.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; - } - } - - return { ok: true }; - }); - -app.listen(3000); +if (cluster.isPrimary) { + for (let i = 0; i < os.availableParallelism(); i++) + cluster.fork() +} else { + await import('./server') + console.log(`Worker ${process.pid} started`) +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..8071189 --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,225 @@ +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 { + PrintfulService, + SyncService, + WebflowService, + Printful, + Webflow, + FetchError, +} from "@blade-and-brawn/commerce"; +import zipcodesUs from "zipcodes-us"; +import z from "zod"; + +const levelCalculator = new LevelCalculator( + new Standards(DEFAULT_STANDARDS_DATA), +); + +export const app = new Elysia() + .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({ + FetchError, + }) + + .onError(async ({ code, error }) => { + switch (code) { + case "FetchError": + console.error(error.message, await error.parse()); + return error; + } + }) + + .onAfterHandle(({ request, set }) => { + console.log( + JSON.stringify({ + lvl: "info", + msg: "req", + method: request.method, + path: new URL(request.url).pathname, + status: set.status ?? 200, + }), + ); + }) + + .get("/", () => "Hello world") + + // 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 ({ }) => SyncService.state) + // Full sync + .post("/", async ({ }) => { + if (SyncService.state.isSyncing) + return status(409, { error: "Sync already in progress" }); + + await SyncService.sync(); + return { ok: true }; + }) + // Per-product sync + .post("/:printfulProductId", async ({ params }) => { + if (SyncService.state.isSyncing) + return status(409, { error: "Sync already in progress" }); + + await SyncService.sync(params.printfulProductId); + return { ok: true }; + }, { params: z.object({ printfulProductId: z.number() }) }), + ) + .get("/:printfulProductId", async ({ params }) => { + const printfulProductId = +params.printfulProductId; + const printfulProduct = + await PrintfulService.Products.get(printfulProductId); + if (!printfulProduct) { + return new NotFoundError(); + } + + const webflowProductId = + printfulProduct.sync_product.external_id.split("-")[0]; + if (!webflowProductId) { + return new NotFoundError(); + } + + const webflowProduct = + await WebflowService.Products.get(webflowProductId); + + return { printfulProduct, webflowProduct }; + }), + ) + + // 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; + await SyncService.sync(printfulProduct.id); + break; + } + case Printful.Webhook.Event.ProductDeleted: { + await WebflowService.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; + + await WebflowService.Orders.update(webflowOrderId, { + shippingTrackingURL: shipInfo.tracking_url, + shippingTracking: shipInfo.tracking_number, + shippingProvider: shipInfo.carrier, + }); + await WebflowService.Orders.fulfill(webflowOrderId, { + sendOrderFulfilledEmail: true, + }); + break; + } + } + + return { ok: true }; + }) + .post("/webhook/webflow", async ({ request, body, set }) => { + if (!WebflowService.Util.verifyWebflowSignature(request, body)) { + set.status = 400; + return "Invalid signature"; + } + + const payload = body as Webflow.Webhook.EventPayload; + + switch (payload.triggerType) { + case Webflow.Webhook.Event.OrderCreated: { + const webflowOrder = payload.payload; + + await PrintfulService.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; + } + } + + return { ok: true }; + }); + +app.listen(3000); diff --git a/package.json b/package.json index 1b1f9df..ac12a16 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ ], "scripts": { "dev:api": "bun run --filter @blade-and-brawn/api dev", + "build:api": "bun run --filter @blade-and-brawn/api build", "dev:portal": "bun run --filter @blade-and-brawn/portal dev", "build:portal": "bun run --filter @blade-and-brawn/portal build" },