Files
platform/apps/api/src/server.ts
T
2026-06-23 22:30:56 -04:00

250 lines
8.9 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";
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"));