From 70c41e359a1db7d710ffad3503b939b6e46d6159 Mon Sep 17 00:00:00 2001 From: Jacob Burgess Date: Thu, 30 Nov 2023 15:20:18 -0800 Subject: [PATCH] enhancement: add resend webhook api handler --- pnpm-lock.yaml | 8 ++ src/commands/add/misc/resend/generators.ts | 118 ++++++++++++++++++++- src/commands/add/misc/resend/index.ts | 26 ++++- src/commands/filePaths/index.ts | 2 + src/commands/filePaths/types.d.ts | 1 + 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05c06c0f..e801b340 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ dependencies: execa: specifier: ^8.0.1 version: 8.0.1 + pluralize: + specifier: ^8.0.0 + version: 8.0.0 strip-json-comments: specifier: ^5.0.1 version: 5.0.1 @@ -1067,6 +1070,11 @@ packages: engines: {node: '>=8.6'} dev: true + /pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + dev: false + /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} diff --git a/src/commands/add/misc/resend/generators.ts b/src/commands/add/misc/resend/generators.ts index 1da91a9f..f6e50161 100644 --- a/src/commands/add/misc/resend/generators.ts +++ b/src/commands/add/misc/resend/generators.ts @@ -2,7 +2,8 @@ // 2. Add component at components/emails/FirstEmailTemplate.tsx // 3. Add route handler at app/api/email/route.ts // 4. Add email utils -// 4. Add email index.ts +// 5. Add email index.ts +// 6. Add webhook handler at app/api/webhooks/resend/route.ts import { formatFilePath, getFilePaths } from "../../../filePaths/index.js"; @@ -173,7 +174,7 @@ const generateApiRoute = () => { const { resend } = getFilePaths(); return `import { EmailTemplate } from "${formatFilePath( resend.firstEmailComponent, - { prefix: "alias", removeExtension: true }, + { prefix: "alias", removeExtension: true } )}"; import { resend } from "${formatFilePath(resend.libEmailIndex, { prefix: "alias", @@ -212,7 +213,7 @@ const generateEmailIndexTs = () => { return `import { Resend } from "resend"; import { env } from "${formatFilePath(init.envMjs, { prefix: "alias", - removeExtension: true, + removeExtension: false, })}"; export const resend = new Resend(env.RESEND_API_KEY); @@ -229,10 +230,121 @@ export const emailSchema = z.object({ `; }; +const generateWebhooksApiRoute = () => { + const { + shared: { init }, + } = getFilePaths(); + return `import { Webhook } from "svix"; +import { headers } from "next/headers"; +import { env } from "${formatFilePath(init.envMjs, { + prefix: "alias", + removeExtension: false, + })}"; + +const webhookSecret = env.RESEND_WEBHOOK_SECRET; + +const resendEventTypes = [ + "email.sent", + "email.delivered", + "email.delivery_delayed", + "email.complained", + "email.bounced", + "email.opened", + "email.clicked", +] as const; + +type ResendEventType = (typeof resendEventTypes)[number]; + +type ResendBaseData = { + created_at: string; + email_id: string; + from: string; + to: string[]; + subject: string; +}; + +type ResendClickData = ResendBaseData & { + click: { + ipAddress: string; + link: string; + timestamp: string; + userAgent: string; + }; +}; + +export type ResendWebhook = + | { + type: Exclude; + created_at: string; + data: ResendBaseData; + } + | { + type: "email.clicked"; + created_at: string; + data: ResendClickData; + }; + + +/** + * Validate this post request was sent by resend + */ +async function validateRequest(request: Request) { + const payloadString = await request.text(); + const headerPayload = headers(); + + const svixHeaders = { + "svix-id": headerPayload.get("svix-id")!, + "svix-timestamp": headerPayload.get("svix-timestamp")!, + "svix-signature": headerPayload.get("svix-signature")!, + }; + + const wh = new Webhook(webhookSecret); + return wh.verify(payloadString, svixHeaders) as ResendWebhook; +} + +export async function POST(request: Request) { + let event: ResendWebhook | null = null; + + try { + event = await validateRequest(request); + } catch (err) { + console.error("Error validating resend webhook:"); + const message = err instanceof Error ? err.message : err; + console.error(message); + return new Response("Resend Webhook Error: " + message, { status: 400 }); + } + + console.log(event); + + switch (event.type) { + case "email.sent": + break; + case "email.delivered": + break; + case "email.delivery_delayed": + break; + case "email.complained": + break; + case "email.bounced": + break; + case "email.opened": + break; + case "email.clicked": + break; + default: + break; + } + + return new Response(null, { status: 200 }); +} +`; +}; + export const resendGenerators = { generateResendPage, generateEmailTemplateComponent, generateApiRoute, generateEmailIndexTs, generateEmailUtilsTs, + generateWebhooksApiRoute, }; diff --git a/src/commands/add/misc/resend/index.ts b/src/commands/add/misc/resend/index.ts index c9da0749..7d5a5068 100644 --- a/src/commands/add/misc/resend/index.ts +++ b/src/commands/add/misc/resend/index.ts @@ -27,6 +27,7 @@ export const addResend = async (packagesBeingInstalled: AvailablePackage[]) => { generateEmailIndexTs, generateApiRoute, generateEmailTemplateComponent, + generateWebhooksApiRoute, } = resendGenerators; // 1. Add page at app/resend/page.tsx @@ -73,12 +74,29 @@ export const addResend = async (packagesBeingInstalled: AvailablePackage[]) => { generateEmailIndexTs() ); - // 6. Add items to .env - addToDotEnv([{ key: "RESEND_API_KEY", value: "" }], rootPath, true); - // 7. Install packages (resend) + // 6. Add webhook handler at app/api/webhooks/resend/route.ts + createFile( + formatFilePath(resend.resendWebhooksApiRoute, { + prefix: "rootPath", + removeExtension: false, + }), + generateWebhooksApiRoute() + ); + + // 7. Add items to .env + addToDotEnv( + [ + { key: "RESEND_API_KEY", value: "" }, + { key: "RESEND_WEBHOOK_SECRET", value: "whsec..." }, + ], + rootPath, + true + ); + + // 8. Install packages (resend) await installPackages( { - regular: `resend${orm === null ? " zod @t3-oss/env-nextjs" : ""}`, + regular: `resend${orm === null ? " zod @t3-oss/env-nextjs" : ""} svix`, dev: "", }, preferredPackageManager diff --git a/src/commands/filePaths/index.ts b/src/commands/filePaths/index.ts index 460b7fe0..07cccce8 100644 --- a/src/commands/filePaths/index.ts +++ b/src/commands/filePaths/index.ts @@ -69,6 +69,7 @@ export const paths: { t3: Paths; normal: Paths } = { emailApiRoute: "app/api/email/route.ts", libEmailIndex: "lib/email/index.ts", firstEmailComponent: "components/emails/FirstEmail.tsx", + resendWebhooksApiRoute: "app/api/webhooks/resend/route.ts", }, stripe: { stripeIndex: "lib/stripe/index.ts", @@ -159,6 +160,7 @@ export const paths: { t3: Paths; normal: Paths } = { emailApiRoute: "app/api/email/route.ts", libEmailIndex: "lib/email/index.ts", firstEmailComponent: "components/emails/FirstEmail.tsx", + resendWebhooksApiRoute: "app/api/webhooks/resend/route.ts", }, stripe: { stripeIndex: "lib/stripe/index.ts", diff --git a/src/commands/filePaths/types.d.ts b/src/commands/filePaths/types.d.ts index a3cc79e1..85758d9b 100644 --- a/src/commands/filePaths/types.d.ts +++ b/src/commands/filePaths/types.d.ts @@ -84,5 +84,6 @@ export type Paths = { emailApiRoute: string; emailUtils: string; libEmailIndex: string; + resendWebhooksApiRoute: string; }; };