Cleanup
This commit is contained in:
+77
-75
@@ -5,19 +5,20 @@ import {
|
||||
PlayerSchema,
|
||||
} from "@blade-and-brawn/domain"
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { Elysia, NotFoundError, redirect, status, t } from "elysia";
|
||||
import { Elysia, 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 { BigIntIdSchema, 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 { DatabaseError } from "pg";
|
||||
import { CalculatorService, CalculatorUnavailableError } from "./services/calculator";
|
||||
import { StandardsParamsSchema } from "@blade-and-brawn/calculator";
|
||||
import { StandardsService } from "./services/standards";
|
||||
@@ -62,14 +63,14 @@ const authPlugin = new Elysia({ name: "auth" })
|
||||
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");
|
||||
if (!token || token.role !== "admin" || !token.accountId || !token.sessionId) return status(401, { error: "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");
|
||||
if (!token || !token.role || !token.accountId || !token.sessionId) return status(401, { error: "Unauthorized" });
|
||||
return { role: token.role.toString(), sessionId: token.sessionId.toString(), accountId: token.accountId.toString() };
|
||||
}
|
||||
}
|
||||
@@ -106,6 +107,11 @@ export const app = new Elysia()
|
||||
})
|
||||
|
||||
.onError(({ code, error }) => {
|
||||
// Database Errors
|
||||
if (error instanceof DatabaseError && error.code === "23505")
|
||||
return status(409, { error: "Conflicts with an existing record" });
|
||||
|
||||
// Custom Errors
|
||||
switch (code) {
|
||||
case "PrintfulError":
|
||||
case "WebflowError":
|
||||
@@ -126,16 +132,18 @@ export const app = new Elysia()
|
||||
}
|
||||
})
|
||||
|
||||
.onAfterResponse(({ request, status, path }) => {
|
||||
.onAfterResponse(({ request, set, path, responseValue }) => {
|
||||
if (env.NODE_ENV === "development") {
|
||||
const skip: Record<string, string[]> = { "/commerce/products/sync/": ["GET"] };
|
||||
if (skip[path]?.includes(request.method)) return;
|
||||
}
|
||||
|
||||
log.info({
|
||||
const failed = Number(set.status) >= 400;
|
||||
log[failed ? "warn" : "info"]({
|
||||
method: request.method,
|
||||
path,
|
||||
status
|
||||
status: set.status,
|
||||
...(failed && { response: responseValue }),
|
||||
}, "request");
|
||||
})
|
||||
|
||||
@@ -146,7 +154,7 @@ export const app = new Elysia()
|
||||
.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");
|
||||
if (!account || !password_match) return status(401, { error: "Invalid credentials" });
|
||||
|
||||
auth.set({
|
||||
value: await jwt.sign({ role: account.role, sessionId: randomUUIDv7(), accountId: account.id, exp: JWT_EXP }),
|
||||
@@ -184,10 +192,10 @@ export const app = new Elysia()
|
||||
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.error) return 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");
|
||||
if (!query.state) return status(400, { error: "No Discord OAuth2 state query parameter provided" });
|
||||
if (query.state !== authDiscord.value) return status(400, { error: "Invalid Discord OAuth state" });
|
||||
|
||||
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
|
||||
method: "POST",
|
||||
@@ -204,7 +212,7 @@ export const app = new Elysia()
|
||||
});
|
||||
if (!tokenRes.ok) {
|
||||
const errorBody = await tokenRes.json().catch(() => null);
|
||||
throw status(502, { error: errorBody ?? "Discord token exchange failed" });
|
||||
return status(502, { error: errorBody ?? "Discord token exchange failed" });
|
||||
}
|
||||
|
||||
const tokenResPayload = await tokenRes.json();
|
||||
@@ -215,7 +223,7 @@ export const app = new Elysia()
|
||||
});
|
||||
if (!identityRes.ok) {
|
||||
const errorBody = await identityRes.json().catch(() => null);
|
||||
throw status(502, { error: errorBody ?? "Failed to fetch Discord identity" });
|
||||
return status(502, { error: errorBody ?? "Failed to fetch Discord identity" });
|
||||
}
|
||||
|
||||
const identityResPayload = await identityRes.json();
|
||||
@@ -274,7 +282,7 @@ export const app = new Elysia()
|
||||
.post("/standards/config/switch", async ({ body: { standardsConfigId } }) => {
|
||||
await s.Calculator.Standards.Config.switch(standardsConfigId);
|
||||
}, {
|
||||
body: t.Object({ standardsConfigId: t.String() })
|
||||
body: t.Object({ standardsConfigId: BigIntIdSchema })
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -282,7 +290,7 @@ export const app = new Elysia()
|
||||
.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 })
|
||||
body: t.Object({ name: t.String(), datasetId: BigIntIdSchema, params: StandardsParamsSchema })
|
||||
})
|
||||
.get("/configs", async () => {
|
||||
return await s.Standards.Configs.list();
|
||||
@@ -290,29 +298,29 @@ export const app = new Elysia()
|
||||
.get("/configs/:id", async ({ params: { id } }) => {
|
||||
return await s.Standards.Configs.get(id);
|
||||
}, {
|
||||
params: t.Object({ id: t.String() })
|
||||
params: t.Object({ id: BigIntIdSchema })
|
||||
})
|
||||
.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 })
|
||||
params: t.Object({ id: BigIntIdSchema }),
|
||||
body: t.Object({ name: t.String(), datasetId: BigIntIdSchema, params: StandardsParamsSchema })
|
||||
})
|
||||
.delete("/configs/:id", async ({ params: { id } }) => {
|
||||
await s.Standards.Configs.delete(id);
|
||||
}, { params: t.Object({ id: t.String() }) })
|
||||
}, { params: t.Object({ id: BigIntIdSchema }) })
|
||||
.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() })
|
||||
params: t.Object({ id: BigIntIdSchema })
|
||||
})
|
||||
.patch("/datasets/:id", async ({ params: { id }, body: { name } }) => {
|
||||
await s.Standards.Datasets.update(id, name);
|
||||
}, {
|
||||
params: t.Object({ id: t.String() }),
|
||||
params: t.Object({ id: BigIntIdSchema }),
|
||||
body: t.Object({ name: t.String() })
|
||||
})
|
||||
)
|
||||
@@ -343,7 +351,7 @@ export const app = new Elysia()
|
||||
// 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");
|
||||
if (!latestSyncState) return status(404, { error: "No product sync found for the provided session" });
|
||||
return latestSyncState;
|
||||
})
|
||||
// Run sync
|
||||
@@ -362,10 +370,10 @@ export const app = new Elysia()
|
||||
)
|
||||
.get("/:pProductId", async ({ params: { pProductId } }) => {
|
||||
const pProduct = await s.Commerce.Printful.Products.get(pProductId);
|
||||
if (!pProduct) throw new NotFoundError("Missing printful product");
|
||||
if (!pProduct) return status(404, { error: "Missing printful product" });
|
||||
|
||||
const wProductId = pProduct.sync_product.external_id.split("-")[0];
|
||||
if (!wProductId) throw new NotFoundError("Missing webflow product ID");
|
||||
if (!wProductId) return status(404, { error: "Missing webflow product ID" });
|
||||
|
||||
const wProduct = await s.Commerce.Webflow.Products.get(wProductId);
|
||||
|
||||
@@ -398,7 +406,7 @@ export const app = new Elysia()
|
||||
})
|
||||
.get("/:wOrderId", async ({ params: { wOrderId } }) => {
|
||||
const wOrder = await s.Commerce.Webflow.Orders.get(wOrderId);
|
||||
if (!wOrder) throw new NotFoundError("Missing webflow order");
|
||||
if (!wOrder) return status(404, { error: "Missing webflow order" });
|
||||
|
||||
const pOrder = await s.Commerce.Printful.Orders.get(`@${wOrder.orderId}`);
|
||||
|
||||
@@ -406,10 +414,10 @@ export const app = new Elysia()
|
||||
}, { 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");
|
||||
if (!wOrder) return status(404, { error: "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" });
|
||||
if (pOrder) return status(409, { error: "Cannot sync an already synced webflow order" });
|
||||
|
||||
await s.Commerce.Apparel.Orders.Queue.enqueue({
|
||||
type: "apparel_order_create",
|
||||
@@ -441,16 +449,16 @@ export const app = new Elysia()
|
||||
})
|
||||
.get("/:id", async ({ params: { id } }) => {
|
||||
const event = await s.Events.get(id);
|
||||
if (!event) throw new NotFoundError("Event not found");
|
||||
if (!event) return status(404, { error: "Event not found" });
|
||||
return { ...event, status: EventsService.status(event) };
|
||||
}, {
|
||||
params: t.Object({ id: t.String() })
|
||||
params: t.Object({ id: BigIntIdSchema })
|
||||
})
|
||||
.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");
|
||||
if (!retried) return status(404, { error: "Event not found or not in a failed state" });
|
||||
}, {
|
||||
params: t.Object({ id: t.String() })
|
||||
params: t.Object({ id: BigIntIdSchema })
|
||||
})
|
||||
.get("/groups", async ({ }) => queues.map((q) => q.group))
|
||||
)
|
||||
@@ -460,7 +468,7 @@ export const app = new Elysia()
|
||||
// TODO: for added security, could enforce bot or admin only access
|
||||
.get("/:id/stats", async ({ params: { id } }) => {
|
||||
const stats = await s.Accounts.stats(id);
|
||||
if (!stats) throw new NotFoundError("Account stats not found");
|
||||
if (!stats) return status(404, { error: "Account stats not found" });
|
||||
return stats;
|
||||
}, {
|
||||
params: t.Object({ id: t.String() })
|
||||
@@ -468,7 +476,7 @@ export const app = new Elysia()
|
||||
.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");
|
||||
if (!account) return status(404, { error: "Account not found" });
|
||||
return account;
|
||||
}, {
|
||||
params: t.Object({ id: t.String() })
|
||||
@@ -477,7 +485,7 @@ export const app = new Elysia()
|
||||
.group("/me", { auth: true }, (app) => app
|
||||
.get("/stats", async ({ accountId }) => {
|
||||
const stats = await s.Accounts.stats(accountId);
|
||||
if (!stats) throw new NotFoundError("Account stats not found");
|
||||
if (!stats) return status(404, { error: "Account stats not found" });
|
||||
return stats;
|
||||
})
|
||||
.post("/assessments", async ({ body: { player, activityPerformances }, accountId }) => {
|
||||
@@ -494,55 +502,48 @@ export const app = new Elysia()
|
||||
})
|
||||
.delete("/assessments/:id", async ({ params: { id }, accountId }) => {
|
||||
const deleted = await s.Assessments.delete(id, accountId);
|
||||
if (!deleted) throw new NotFoundError("Assessment not found");
|
||||
if (!deleted) return status(404, { error: "Assessment not found" });
|
||||
}, {
|
||||
params: t.Object({ id: t.String() })
|
||||
params: t.Object({ id: BigIntIdSchema })
|
||||
})
|
||||
.post("/verifications", async ({ body: { assessmentId, activityMediaKeys }, accountId }) => {
|
||||
const assessment = await s.Assessments.get(assessmentId);
|
||||
if (!assessment || assessment.account_id !== accountId) throw new NotFoundError("Assessment not found");
|
||||
const assessment = await s.Assessments.get(assessmentId, accountId);
|
||||
if (!assessment) return status(404, { error: "Assessment not found" });
|
||||
|
||||
// TODO: only allow a single active non-completed verification at a time
|
||||
// TODO: return R2 POST urls for the media
|
||||
|
||||
const created = await s.Verifications.create(assessmentId, activityMediaKeys);
|
||||
if (!created) throw status(409, { error: "This assessment already has a verification" });
|
||||
if (!created) return status(409, { error: "This assessment already has a verification" });
|
||||
return created;
|
||||
}, {
|
||||
body: t.Object({
|
||||
assessmentId: t.String(),
|
||||
assessmentId: BigIntIdSchema,
|
||||
activityMediaKeys: ActivityMediaKeysSchema,
|
||||
})
|
||||
})
|
||||
.patch("/verifications/:id", async ({ params: { id }, body: { assessmentId, activityMediaKeys }, accountId }) => {
|
||||
const verification = await s.Verifications.get(id);
|
||||
if (!verification || verification.account_id !== accountId) throw new NotFoundError("Verification not found");
|
||||
const verification = await s.Verifications.get(id, accountId);
|
||||
if (!verification) return status(404, { error: "Verification not found" });
|
||||
|
||||
if (assessmentId === undefined && Object.keys(activityMediaKeys ?? {}).length === 0)
|
||||
throw status(400, { error: "Nothing to update" });
|
||||
return status(400, { error: "Nothing to update" });
|
||||
|
||||
if (assessmentId !== undefined) {
|
||||
const assessment = await s.Assessments.get(assessmentId);
|
||||
if (!assessment || assessment.account_id !== accountId) throw new NotFoundError("Assessment not found");
|
||||
}
|
||||
if (assessmentId !== undefined && !await s.Assessments.get(assessmentId, accountId))
|
||||
return status(404, { error: "Assessment not found" });
|
||||
|
||||
const updated = await s.Verifications.update(id, { assessmentId, activityMediaKeys })
|
||||
.catch((err) => {
|
||||
if (err?.code === "23505") throw status(409, { error: "That assessment already has a verification" });
|
||||
throw err;
|
||||
});
|
||||
if (!updated) throw status(409, { error: `Cannot update a ${verification.status} verification` });
|
||||
const updated = await s.Verifications.update(id, { assessmentId, activityMediaKeys });
|
||||
if (!updated) return status(409, { error: `Cannot update a ${verification.status} verification` });
|
||||
return updated;
|
||||
}, {
|
||||
params: t.Object({ id: t.String() }),
|
||||
params: t.Object({ id: BigIntIdSchema }),
|
||||
body: t.Object({
|
||||
assessmentId: t.Optional(t.String()),
|
||||
assessmentId: t.Optional(BigIntIdSchema),
|
||||
activityMediaKeys: t.Optional(ActivityMediaKeysSchema),
|
||||
})
|
||||
})
|
||||
.post("/verifications/:id/submit", async ({ params: { id }, accountId }) => {
|
||||
const verification = await s.Verifications.get(id);
|
||||
if (!verification || verification.account_id !== accountId) throw new NotFoundError("Verification not found");
|
||||
if (!await s.Verifications.get(id, accountId)) return status(404, { error: "Verification not found" });
|
||||
|
||||
const submitted = await s.Verifications.submit(id);
|
||||
if (submitted) {
|
||||
@@ -550,18 +551,18 @@ export const app = new Elysia()
|
||||
return submitted;
|
||||
}
|
||||
|
||||
const current = await s.Verifications.get(id);
|
||||
if (!current) throw new NotFoundError("Verification not found");
|
||||
const current = await s.Verifications.get(id, accountId);
|
||||
if (!current) return status(404, { error: "Verification not found" });
|
||||
if (current.status === "submitted") return { id: current.id, status: current.status };
|
||||
|
||||
const missingActivityMediaKeys = VerificationsService.missingActivityMediaKeys(current);
|
||||
throw status(409, {
|
||||
return status(409, {
|
||||
error: current.status === "draft" && missingActivityMediaKeys.length > 0
|
||||
? `Missing media for: ${missingActivityMediaKeys.join(", ")}`
|
||||
: `Cannot submit a ${current.status} verification`
|
||||
});
|
||||
}, {
|
||||
params: t.Object({ id: t.String() })
|
||||
params: t.Object({ id: BigIntIdSchema })
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -569,7 +570,7 @@ export const app = new Elysia()
|
||||
// ASSESSMENTS
|
||||
.group("/assessments", { authAdmin: true }, (app) => app
|
||||
.post("/", async ({ body: { player, activityPerformances, id } }) => {
|
||||
await s.Assessments.create(player, activityPerformances, id);
|
||||
return await s.Assessments.create(player, activityPerformances, id);
|
||||
}, {
|
||||
body: t.Object({
|
||||
player: PlayerSchema,
|
||||
@@ -579,9 +580,10 @@ export const app = new Elysia()
|
||||
})
|
||||
.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");
|
||||
if (!updated) return status(404, { error: "Assessment not found" });
|
||||
return updated;
|
||||
}, {
|
||||
params: t.Object({ id: t.String() }),
|
||||
params: t.Object({ id: BigIntIdSchema }),
|
||||
body: t.Object({
|
||||
player: PlayerSchema,
|
||||
activityPerformances: t.Array(ActivityPerformanceSchema),
|
||||
@@ -606,20 +608,20 @@ export const app = new Elysia()
|
||||
})
|
||||
.get("/:id", async ({ params: { id } }) => {
|
||||
const verification = await s.Verifications.get(id);
|
||||
if (!verification) throw new NotFoundError("Verification not found");
|
||||
if (!verification) return status(404, { error: "Verification not found" });
|
||||
return verification;
|
||||
}, {
|
||||
params: t.Object({ id: t.String() })
|
||||
params: t.Object({ id: BigIntIdSchema })
|
||||
})
|
||||
.post("/:id/request-action", async ({ params: { id }, body: { reviewerNotes, activityVerifications } }) => {
|
||||
const updated = await s.Verifications.requestAction(id, reviewerNotes ?? null, activityVerifications);
|
||||
if (updated) return updated;
|
||||
|
||||
const verification = await s.Verifications.get(id);
|
||||
if (!verification) throw new NotFoundError("Verification not found");
|
||||
throw status(409, { error: `Cannot request action on a ${verification.status} verification` });
|
||||
if (!verification) return status(404, { error: "Verification not found" });
|
||||
return status(409, { error: `Cannot request action on a ${verification.status} verification` });
|
||||
}, {
|
||||
params: t.Object({ id: t.String() }),
|
||||
params: t.Object({ id: BigIntIdSchema }),
|
||||
body: t.Object({
|
||||
reviewerNotes: t.Optional(t.String()),
|
||||
activityVerifications: ActivityVerificationsSchema,
|
||||
@@ -630,10 +632,10 @@ export const app = new Elysia()
|
||||
if (updated) return updated;
|
||||
|
||||
const verification = await s.Verifications.get(id);
|
||||
if (!verification) throw new NotFoundError("Verification not found");
|
||||
throw status(409, { error: `Cannot complete a ${verification.status} verification` });
|
||||
if (!verification) return status(404, { error: "Verification not found" });
|
||||
return status(409, { error: `Cannot complete a ${verification.status} verification` });
|
||||
}, {
|
||||
params: t.Object({ id: t.String() }),
|
||||
params: t.Object({ id: BigIntIdSchema }),
|
||||
body: t.Object({
|
||||
activityVerifications: ActivityVerificationsSchema,
|
||||
})
|
||||
@@ -644,7 +646,7 @@ export const app = new Elysia()
|
||||
.post("/webhooks/printful", async ({ body, query }) => {
|
||||
// https://webflow.com/integrations/printful
|
||||
if (!s.Commerce.Printful.Util.verifySecret(query.secret))
|
||||
throw status(400, "Invalid secret");
|
||||
return status(400, { error: "Invalid secret" });
|
||||
|
||||
const payload = body as Printful.Webhook.EventPayload;
|
||||
|
||||
@@ -667,7 +669,7 @@ export const app = new Elysia()
|
||||
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");
|
||||
if (!wProductId) return status(404, { error: "Missing webflow product ID" });
|
||||
|
||||
await s.Commerce.Apparel.Syncs.Queue.enqueue({
|
||||
type: "apparel_sync_delete",
|
||||
@@ -699,7 +701,7 @@ export const app = new Elysia()
|
||||
}, { 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");
|
||||
return status(400, { error: "Invalid signature" });
|
||||
|
||||
const payload = body as Webflow.Webhook.EventPayload;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user