- Introduced a comprehensive markdown document outlining implementation rules for Convex functions, including syntax, registration, HTTP endpoints, and TypeScript usage. - Created a new configuration file for the Convex app, integrating the Resend service. - Added a new HTTP route for handling Shippo webhooks to ensure proper response handling. - Implemented integration tests for order timeline events, covering various scenarios including order fulfillment and status changes. - Enhanced existing functions with type safety improvements and additional validation logic. This commit establishes clear guidelines for backend development and improves the overall structure and reliability of the Convex application.
149 lines
4.3 KiB
TypeScript
149 lines
4.3 KiB
TypeScript
import { httpRouter } from "convex/server";
|
|
import { httpAction } from "./_generated/server";
|
|
import { internal } from "./_generated/api";
|
|
import type { WebhookEvent } from "@clerk/backend";
|
|
import { Webhook } from "svix";
|
|
|
|
const http = httpRouter();
|
|
|
|
http.route({
|
|
path: "/clerk-users-webhook",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const event = await validateRequest(request);
|
|
if (!event) return new Response("Error", { status: 400 });
|
|
|
|
switch (event.type) {
|
|
case "user.created":
|
|
case "user.updated":
|
|
await ctx.runMutation(internal.users.upsertFromClerk, {
|
|
externalId: event.data.id,
|
|
name:
|
|
`${event.data.first_name ?? ""} ${event.data.last_name ?? ""}`.trim(),
|
|
email: event.data.email_addresses[0]?.email_address ?? "",
|
|
avatarUrl: event.data.image_url ?? undefined,
|
|
});
|
|
break;
|
|
case "user.deleted":
|
|
if (event.data.id) {
|
|
await ctx.runMutation(internal.users.deleteFromClerk, {
|
|
externalId: event.data.id,
|
|
});
|
|
}
|
|
break;
|
|
default:
|
|
console.log("Ignored webhook event:", event.type);
|
|
}
|
|
|
|
return new Response(null, { status: 200 });
|
|
}),
|
|
});
|
|
|
|
async function validateRequest(
|
|
req: Request,
|
|
): Promise<WebhookEvent | null> {
|
|
const payload = await req.text();
|
|
const headers = {
|
|
"svix-id": req.headers.get("svix-id")!,
|
|
"svix-timestamp": req.headers.get("svix-timestamp")!,
|
|
"svix-signature": req.headers.get("svix-signature")!,
|
|
};
|
|
try {
|
|
return new Webhook(process.env.CLERK_STOREFRONT_WEBHOOK_SECRET!).verify(
|
|
payload,
|
|
headers,
|
|
) as WebhookEvent;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
http.route({
|
|
path: "/clerk-admin-webhook",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const event = await validateAdminRequest(request);
|
|
if (!event) return new Response("Error", { status: 400 });
|
|
|
|
switch (event.type) {
|
|
case "user.created":
|
|
case "user.updated": {
|
|
const role = (event.data.public_metadata as Record<string, unknown>)
|
|
?.role;
|
|
await ctx.runMutation(internal.users.upsertFromClerk, {
|
|
externalId: event.data.id,
|
|
name:
|
|
`${event.data.first_name ?? ""} ${event.data.last_name ?? ""}`.trim(),
|
|
email: event.data.email_addresses[0]?.email_address ?? "",
|
|
avatarUrl: event.data.image_url ?? undefined,
|
|
role:
|
|
role === "admin" || role === "super_admin" ? role : undefined,
|
|
});
|
|
break;
|
|
}
|
|
case "user.deleted":
|
|
if (event.data.id) {
|
|
await ctx.runMutation(internal.users.deleteFromClerk, {
|
|
externalId: event.data.id,
|
|
});
|
|
}
|
|
break;
|
|
default:
|
|
console.log("Ignored admin webhook event:", event.type);
|
|
}
|
|
|
|
return new Response(null, { status: 200 });
|
|
}),
|
|
});
|
|
|
|
async function validateAdminRequest(
|
|
req: Request,
|
|
): Promise<WebhookEvent | null> {
|
|
const payload = await req.text();
|
|
const headers = {
|
|
"svix-id": req.headers.get("svix-id")!,
|
|
"svix-timestamp": req.headers.get("svix-timestamp")!,
|
|
"svix-signature": req.headers.get("svix-signature")!,
|
|
};
|
|
try {
|
|
return new Webhook(process.env.CLERK_ADMIN_WEBHOOK_SECRET!).verify(
|
|
payload,
|
|
headers,
|
|
) as WebhookEvent;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
http.route({
|
|
path: "/stripe/webhook",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const body = await request.text();
|
|
const signature = request.headers.get("stripe-signature");
|
|
if (!signature) {
|
|
return new Response("Missing stripe-signature header", { status: 400 });
|
|
}
|
|
|
|
const result = await ctx.runAction(internal.stripeActions.handleWebhook, {
|
|
payload: body,
|
|
signature,
|
|
});
|
|
|
|
return new Response(null, { status: result.success ? 200 : 400 });
|
|
}),
|
|
});
|
|
|
|
http.route({
|
|
path: "/shippo/webhook",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const body = await request.text();
|
|
// Always respond 200 first — Shippo retries on non-2xx, so we must not
|
|
// let internal errors cause retry storms. Errors are logged in the action.
|
|
await ctx.runAction(internal.shippoWebhook.handleTrackUpdated, { body });
|
|
return new Response(null, { status: 200 });
|
|
}),
|
|
});
|
|
|
|
export default http; |