315 lines
12 KiB
TypeScript
315 lines
12 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 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<string, string[]> = { "/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
|