Files
platform/apps/api/src/server.ts
T

431 lines
17 KiB
TypeScript

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 { DEFAULT_NAME, 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";
// CONSTANTS
// -----------------------
const EVENT_QUEUE_MANAGE_DELAY_MS = 1000 // 1 sec
const JWT_EXP = "1d";
// SERVICES
// -----------------------
const s = (() => {
const Standards = new StandardsService();
const Calculator = new CalculatorService(DEFAULT_NAME);
const Commerce = new CommerceService();
const Events = new EventsService();
return { Standards, Calculator, Commerce, Events };
})();
// 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({
auth: {
async resolve({ 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() };
}
}
});
// 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()) }) })
.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<string, string[]> = { "/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, cookie: { auth } }) => {
const match = crypto.timingSafeEqual(Buffer.from(body.password, "hex"), Buffer.from(env.ADMIN_PASSWORD, "hex"));
if (!match) throw status(401, "Invalid credentials");
auth.set({
value: await jwt.sign({ sessionId: randomUUIDv7(), exp: JWT_EXP }),
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)
}),
})
// Authenticated
.guard({ auth: true })
.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", { auth: 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", { auth: true }, (app) => app
.group("/products", (app) => app
.get("/", async ({ query }) => {
const pProducts = await s.Commerce.Printful.Products.list({
limit: query.limit,
offset: query.offset,
});
const isSyncedFlags = await Promise.all(
pProducts.map(async (pProduct) => {
const wProductId = pProduct.external_id.split("-")[0];
if (!wProductId) return false;
return (await s.Commerce.Webflow.Products.get(wProductId)) !== undefined;
})
);
return pProducts.map((pProduct, i) => ({ pProduct, isSynced: isSyncedFlags[i] }));
}, {
query: t.Object({
limit: t.Optional(t.Numeric()),
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 = await s.Commerce.Webflow.Orders.list({
status: query.status,
limit: query.limit,
offset: query.offset,
});
const isSyncedFlags = await Promise.all(
wOrders.map(async (wOrder) =>
(await s.Commerce.Printful.Orders.get(`@${wOrder.orderId}`)) !== undefined
)
);
return wOrders.map((wOrder, i) => ({ wOrder, isSynced: isSyncedFlags[i] }));
}, {
query: t.Object({
status: t.Optional(WOrderStatusSchema),
limit: t.Optional(t.Numeric()),
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() }) })
)
)
// EVENTS
.group("/events", { auth: 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))
)
// WEBHOOKS
.post("/webhooks/printful", async ({ body, query }) => {
// https://webflow.com/integrations/printful
const verified = crypto.timingSafeEqual(Buffer.from(query.secret, "hex"), Buffer.from(env.PRINTFUL_WEBHOOK_SECRET, "hex"));
if (!verified) 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.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.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";