633 lines
26 KiB
TypeScript
633 lines
26 KiB
TypeScript
import {
|
|
ActivityPerformanceSchema,
|
|
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 { 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();
|
|
return { Standards, Calculator, Commerce, Accounts, Events, Assessments };
|
|
})();
|
|
|
|
// 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<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: { 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),
|
|
})
|
|
})
|
|
)
|
|
)
|
|
|
|
// 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";
|