import { ActivityPerformanceSchema, ActivityVerificationsSchema, ActivityVideosSchema, PlayerSchema, } from "@blade-and-brawn/domain" import { cors } from "@elysiajs/cors"; import { Elysia, NotFoundError, redirect, status, t } from "elysia"; import { PrintfulError, WebflowError, Printful, Webflow, } from "@blade-and-brawn/commerce"; import { DEFAULT_NAME, DUMMY_PASSWORD_HASH, env, log } from "./util"; import serverTiming from "@elysia/server-timing"; import jwt from "@elysia/jwt"; import { CommerceService, WOrderStatusSchema } from "./services/commerce"; import cluster from "node:cluster"; import { randomUUIDv7, sleep } from "bun"; import { CalculatorService, CalculatorUnavailableError } from "./services/calculator"; import { StandardsParamsSchema } from "@blade-and-brawn/calculator"; import { StandardsService } from "./services/standards"; import { EventsService, EventStatusSchema } from "./services/events"; import { AccountsService, type AccountRole } from "./services/accounts"; import { Value } from "@sinclair/typebox/value"; import { AssessmentsService } from "./services/assessments"; import { VerificationsService, VerificationStatusSchema } from "./services/verifications"; import { Not } from "@sinclair/typebox"; // CONSTANTS // ----------------------- const EVENT_QUEUE_MANAGE_DELAY_MS = 1000 // 1 sec const JWT_EXP = "1d"; const JWT_EXP_SECONDS = 60 * 60 * 24; // keep in sync with JWT_EXP; used for cookie maxAge // SERVICES // ----------------------- const s = (() => { const Standards = new StandardsService(); const Calculator = new CalculatorService(DEFAULT_NAME); const Commerce = new CommerceService(); const Accounts = new AccountsService(Calculator); const Events = new EventsService(); const Assessments = new AssessmentsService(); const Verifications = new VerificationsService(); return { Standards, Calculator, Commerce, Accounts, Events, Assessments, Verifications }; })(); // QUEUES // ----------------------- const queues = [ s.Commerce.Apparel.Syncs.Queue, s.Commerce.Apparel.Orders.Queue, ] as const; // PLUGINS // ----------------------- const authPlugin = new Elysia({ name: "auth" }) .use(jwt({ name: "jwt", secret: env.AUTH_SECRET })) .guard({ cookie: t.Cookie({ auth: t.Optional(t.String()) }) }) .macro({ authAdmin: { async resolve({ jwt, cookie: { auth } }) { const token = auth.value && await jwt.verify(auth.value); if (!token || token.role !== "admin" || !token.accountId || !token.sessionId) throw status(401, "Unauthorized"); return { role: token.role.toString(), sessionId: token.sessionId.toString(), accountId: token.accountId.toString() }; } }, auth: { async resolve({ jwt, cookie: { auth } }) { const token = auth.value && await jwt.verify(auth.value); if (!token || !token.role || !token.accountId || !token.sessionId) throw status(401, "Unauthorized"); return { role: token.role.toString(), sessionId: token.sessionId.toString(), accountId: token.accountId.toString() }; } } }); // 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", ], }), ) .guard({ cookie: t.Cookie({ auth: t.Optional(t.String()), authDiscord: t.Optional(t.String()) }) }) .use(authPlugin) .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 }) => { if (env.NODE_ENV === "development") { const skip: Record = { "/commerce/products/sync/": ["GET"] }; if (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: { password, email }, cookie: { auth } }) => { const account = await s.Accounts.getByEmail(email); const password_match = await Bun.password.verify(password, account?.password_hash ?? DUMMY_PASSWORD_HASH); if (!account || !password_match) throw status(401, "Invalid credentials"); auth.set({ value: await jwt.sign({ role: account.role, sessionId: randomUUIDv7(), accountId: account.id, exp: JWT_EXP }), path: "/", maxAge: JWT_EXP_SECONDS, sameSite: env.NODE_ENV === "production" ? "lax" : "none", httpOnly: true, secure: true, domain: env.NODE_ENV === "production" ? ".bladeandbrawn.com" : undefined }); }, { body: t.Object({ email: t.String(), password: t.String() }) }) .group("/auth/discord", (app) => app .get("/login", async ({ cookie: { authDiscord } }) => { const state = crypto.randomUUID(); authDiscord.set({ value: state, path: "/", maxAge: 60 * 10, // 10 min sameSite: "lax", httpOnly: true, secure: env.NODE_ENV === "production" }); const url = new URL("https://discord.com/api/oauth2/authorize"); url.searchParams.set("client_id", env.BOT_CLIENT_ID); url.searchParams.set("redirect_uri", env.BOT_REDIRECT_URL); url.searchParams.set("response_type", "code"); url.searchParams.set("scope", "identify email"); url.searchParams.set("state", state); return redirect(url.toString(), 302); }) .get("/callback", async ({ query, cookie: { authDiscord, auth }, jwt }) => { if (query.error) throw status(400, { error: query.error_description ? `${query.error}: ${query.error_description}` : query.error }); if (!query.state) throw status(400, "No Discord OAuth2 state query parameter provided"); if (query.state !== authDiscord.value) throw status(400, "Invalid Discord OAuth state"); const tokenRes = await fetch("https://discord.com/api/oauth2/token", { method: "POST", headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ client_id: env.BOT_CLIENT_ID, client_secret: env.BOT_CLIENT_SECRET, grant_type: "authorization_code", code: query.code ?? "", redirect_uri: env.BOT_REDIRECT_URL }).toString() }); if (!tokenRes.ok) { const errorBody = await tokenRes.json().catch(() => null); throw status(502, { error: errorBody ?? "Discord token exchange failed" }); } const tokenResPayload = await tokenRes.json(); Value.Assert(t.Object({ access_token: t.String() }), tokenResPayload); const identityRes = await fetch("https://discord.com/api/users/@me", { headers: { "Authorization": `Bearer ${tokenResPayload.access_token}` } }); if (!identityRes.ok) { const errorBody = await identityRes.json().catch(() => null); throw status(502, { error: errorBody ?? "Failed to fetch Discord identity" }); } const identityResPayload = await identityRes.json(); Value.Assert(t.Object({ id: t.String(), email: t.Optional(t.String()), username: t.String() }), identityResPayload); // Create the account let accountId = (await s.Accounts.get(`@${identityResPayload.id}`))?.id; if (!accountId) { accountId = (await s.Accounts.create( identityResPayload.id, identityResPayload.email, identityResPayload.username )).id; } // Authenticate auth.set({ value: await jwt.sign({ role: "user" satisfies AccountRole, sessionId: randomUUIDv7(), accountId, exp: JWT_EXP }), path: "/", maxAge: JWT_EXP_SECONDS, sameSite: env.NODE_ENV === "production" ? "lax" : "none", httpOnly: true, secure: true, domain: env.NODE_ENV === "production" ? ".bladeandbrawn.com" : undefined }); return redirect(env.BOT_LOGIN_REDIRECT_URL, 302); }, { query: t.Object({ code: t.Optional(t.String()), state: t.Optional(t.String()), error: t.Optional(t.String()), error_description: t.Optional(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) }), }) // Authenticated .guard({ authAdmin: true }, (app) => app .get("/standards/config", async () => { return await s.Calculator.Standards.Config.get(); }) .post("/standards/config/switch", async ({ body: { standardsConfigId } }) => { await s.Calculator.Standards.Config.switch(standardsConfigId); }, { body: t.Object({ standardsConfigId: t.String() }) }) ) ) .group("/standards", { authAdmin: true }, (app) => app .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 }) }) .delete("/configs/:id", async ({ params: { id } }) => { await s.Standards.Configs.delete(id); }, { params: t.Object({ id: t.String() }) }) .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() }) }) .patch("/datasets/:id", async ({ params: { id }, body: { name } }) => { await s.Standards.Datasets.update(id, name); }, { params: t.Object({ id: t.String() }), body: t.Object({ name: t.String() }) }) ) // COMMERCE .group("/commerce", { authAdmin: true }, (app) => app .group("/products", (app) => app .get("/", async ({ query }) => { const [pProducts, wProducts] = await Promise.all([ s.Commerce.Printful.Products.list({ limit: query.limit, offset: query.offset, }), s.Commerce.Webflow.Products.list({ forceAll: true }), ]); const wProductIds = new Set(wProducts.map((wProduct) => wProduct.product.id)); return pProducts.map((pProduct) => { const wProductId = pProduct.external_id.split("-")[0]; return { pProduct, isSynced: wProductId ? wProductIds.has(wProductId) : false }; }); }, { query: t.Object({ limit: t.Optional(t.Numeric({ maximum: 100 })), offset: t.Optional(t.Numeric()), }) }) .group("/sync", (app) => app // Sync status .get("/", async ({ sessionId }) => { const latestSyncState = await s.Commerce.Apparel.Syncs.getLatestSyncState(sessionId); if (!latestSyncState) throw new NotFoundError("No product sync found for the provided session"); return latestSyncState; }) // Run sync .post("/:pProductId?", async ({ params: { pProductId }, sessionId }) => { await s.Commerce.Apparel.Syncs.Queue.enqueue({ type: "apparel_sync_update", source: "portal", payload: { session: { id: sessionId, name: "Portal" }, filter: { pProductIds: pProductId ? [pProductId] : undefined } } }); }, { 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() }) }) ) .group("/orders", (app) => app .get("/", async ({ query }) => { const [wOrders, pOrders] = await Promise.all([ s.Commerce.Webflow.Orders.list({ status: query.status, limit: query.limit, offset: query.offset, }), s.Commerce.Printful.Orders.list({ forceAll: true }), ]); const pOrderExternalIds = new Set(pOrders.map((pOrder) => pOrder.external_id)); return wOrders.map((wOrder) => ({ wOrder, isSynced: pOrderExternalIds.has(wOrder.orderId), })); }, { query: t.Object({ status: t.Optional(WOrderStatusSchema), limit: t.Optional(t.Numeric({ maximum: 100 })), offset: t.Optional(t.Numeric()), }) }) .get("/:wOrderId", async ({ params: { wOrderId } }) => { const wOrder = await s.Commerce.Webflow.Orders.get(wOrderId); if (!wOrder) throw new NotFoundError("Missing webflow order"); const pOrder = await s.Commerce.Printful.Orders.get(`@${wOrder.orderId}`); return { wOrder, pOrder }; }, { params: t.Object({ wOrderId: t.String() }) }) .post("/sync/:wOrderId", async ({ params: { wOrderId } }) => { const wOrder = await s.Commerce.Webflow.Orders.get(wOrderId); if (!wOrder) throw new NotFoundError("Missing webflow order"); const pOrder = await s.Commerce.Printful.Orders.get(`@${wOrder.orderId}`); if (pOrder) throw status(409, { error: "Cannot sync an already synced webflow order" }); await s.Commerce.Apparel.Orders.Queue.enqueue({ type: "apparel_order_create", source: "portal", payload: { wOrder } }); }, { params: t.Object({ wOrderId: t.String() }) }) ) ) // EVENTS .group("/events", { authAdmin: true }, (app) => app .get("/", async ({ query }) => { const events = await s.Events.list({ filter: { status: query.status, type: query.type, group: query.group }, limit: query.limit, offset: query.offset, }); return events.map((event) => ({ ...event, status: EventsService.status(event) })); }, { query: t.Object({ status: t.Optional(EventStatusSchema), type: t.Optional(t.String()), group: t.Optional(t.String()), limit: t.Optional(t.Numeric()), offset: t.Optional(t.Numeric()), }) }) .get("/:id", async ({ params: { id } }) => { const event = await s.Events.get(id); if (!event) throw new NotFoundError("Event not found"); return { ...event, status: EventsService.status(event) }; }, { params: t.Object({ id: t.String() }) }) .post("/:id/retry", async ({ params: { id } }) => { const retried = await s.Events.retry(id); if (!retried) throw new NotFoundError("Event not found or not in a failed state"); }, { params: t.Object({ id: t.String() }) }) .get("/groups", async ({ }) => queues.map((q) => q.group)) ) // ACCOUNTS .group("/accounts", (app) => app .get("/:id/stats", async ({ params: { id } }) => { const stats = await s.Accounts.stats(id); if (!stats) throw new NotFoundError("Account stats not found"); return stats; }, { params: t.Object({ id: t.String() }) }) .guard({ auth: true }, (app) => app .get("/me/stats", async ({ accountId }) => { const stats = await s.Accounts.stats(accountId); if (!stats) throw new NotFoundError("Account stats not found"); return stats; }) ) .guard({ authAdmin: true }, (app) => app .get("/:id", async ({ params: { id } }) => { const account = await s.Accounts.get(id); if (!account) throw new NotFoundError("Account not found"); return account; }, { params: t.Object({ id: t.String() }) }) ) ) // ASSESSMENTS .group("/assessments", (app) => app .guard({ auth: true }, (app) => app .post("/me", async ({ body: { player, activityPerformances }, accountId }) => { return await s.Assessments.create(player, activityPerformances, accountId); }, { body: t.Object({ player: PlayerSchema, activityPerformances: t.Array(ActivityPerformanceSchema) }) }) .get("/me", async ({ accountId }) => { return await s.Assessments.list({ filter: { accountId } }); }) .delete("/me/:id", async ({ params: { id }, accountId }) => { const deleted = await s.Assessments.delete(id, accountId); if (!deleted) throw new NotFoundError("Assessment not found"); }, { params: t.Object({ id: t.String() }) }) ) .guard({ authAdmin: true }, (app) => app .post("/", async ({ body: { player, activityPerformances, id } }) => { await s.Assessments.create(player, activityPerformances, id); }, { body: t.Object({ player: PlayerSchema, activityPerformances: t.Array(ActivityPerformanceSchema), id: t.Optional(t.String()), }) }) .put("/:id", async ({ body: { player, activityPerformances }, params: { id } }) => { const updated = await s.Assessments.update(id, player, activityPerformances); if (!updated) throw new NotFoundError("Assessment not found"); }, { params: t.Object({ id: t.String() }), body: t.Object({ player: PlayerSchema, activityPerformances: t.Array(ActivityPerformanceSchema), }) }) ) ) // VERIFICATIONS .group("/verifications", (app) => app .guard({ auth: true }, (app) => app .post("/me", async ({ body: { assessmentId, activityVideoUrls }, accountId }) => { const requested = await s.Verifications.request(assessmentId, accountId, activityVideoUrls); if (!requested) throw new NotFoundError("Assessment not found"); return requested; }, { body: t.Object({ assessmentId: t.String(), activityVideoUrls: ActivityVideosSchema, }) }) ) .guard({ authAdmin: true }, (app) => app .get("/", async ({ query }) => { return await s.Verifications.list({ filter: { status: query.status }, limit: query.limit, offset: query.offset, }); }, { query: t.Object({ status: t.Optional(VerificationStatusSchema), limit: t.Optional(t.Numeric()), offset: t.Optional(t.Numeric()), }) }) .get("/:id", async ({ params: { id } }) => { const verification = await s.Verifications.get(id); if (!verification) throw new NotFoundError("Verification not found"); return verification; }, { params: t.Object({ id: t.String() }) }) .post("/:id/request-action", async ({ params: { id }, body: { reviewerNotes, activityVerifications } }) => { const verification = await s.Verifications.get(id); if (!verification) throw new NotFoundError("Verification not found"); if (verification.status === "completed") throw status(409, { error: "Cannot modify a completed verification" }); const updated = await s.Verifications.requestAction(id, reviewerNotes ?? null, activityVerifications); if (!updated) throw new NotFoundError("Verification not found"); }, { params: t.Object({ id: t.String() }), body: t.Object({ reviewerNotes: t.Optional(t.String()), activityVerifications: ActivityVerificationsSchema, }) }) .post("/:id/complete", async ({ params: { id }, body: { activityVerifications }, accountId }) => { const verification = await s.Verifications.get(id); if (!verification) throw new NotFoundError("Verification not found"); if (verification.status === "completed") throw status(409, { error: "Cannot modify a completed verification" }); const updated = await s.Verifications.complete(id, accountId, activityVerifications); if (!updated) throw new NotFoundError("Verification not found"); }, { params: t.Object({ id: t.String() }), body: t.Object({ activityVerifications: ActivityVerificationsSchema, }) }) ) ) // WEBHOOKS .post("/webhooks/printful", async ({ body, query }) => { // https://webflow.com/integrations/printful if (!s.Commerce.Printful.Util.verifySecret(query.secret)) throw status(400, "Invalid secret"); 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.Apparel.Syncs.Queue.enqueue({ type: "apparel_sync_update", source: "printful", payload: { 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.Apparel.Syncs.Queue.enqueue({ type: "apparel_sync_delete", source: "printful", payload: { wProductId } }); break; } case Printful.Webhook.Event.PackageShipped: { const wOrderId = payload.data.order.external_id; const shipment = payload.data.shipment; log.info({ wOrderId, carrier: shipment.carrier, tracking: shipment.tracking_number }, "printful webhook: package shipped"); // TODO: possible to have multiple packages shipped // order should NOT be immediately fulfulled in that case await s.Commerce.Apparel.Orders.Queue.enqueue({ type: "apparel_order_fulfill", source: "printful", payload: { wOrderId, shipment } }); break; } default: log.warn({ type: (payload as any).type }, "printful webhook: unhandled event type"); } }, { query: t.Object({ secret: t.String() }) }) .post("/webhooks/webflow", async ({ request, body }) => { if (!s.Commerce.Webflow.Util.verifySecret(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.Apparel.Orders.Queue.enqueue({ type: "apparel_order_create", source: "webflow", payload: { wOrder } }); 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") // MANAGE QUEUES for (const queue of queues) { (async () => { while (true) { try { // clean, if ready if ((Date.now() - queue.lastCleanDate.getTime()) >= EventsService.CONCURRENCY_TIMEOUT_MS) await queue.clean().catch((err) => log.error({ name: queue.group, err })); // drain await queue.drain(); } catch (err) { log.error({ name: queue.group, err }, "error occurred during queue management"); } await sleep(EVENT_QUEUE_MANAGE_DELAY_MS); } })(); } }); export type API = typeof app export { type EventStatus } from "./services/events"; export { type VerificationStatus } from "./services/verifications";