From 5e26b5abefa3159743d5314599964b7beca4f211 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Tue, 29 Oct 2024 23:09:03 -0400 Subject: [PATCH 01/17] Started email stuff --- apps/web/.env.example | 1 + apps/web/env.js | 2 ++ bun.lockb | Bin 263108 -> 268060 bytes packages/email/eslint.config.js | 9 ++++++ packages/email/package.json | 28 ++++++++++++++++++ packages/email/src/index.ts | 41 +++++++++++++++++++++++++++ packages/email/src/test.ts | 28 ++++++++++++++++++ packages/email/tsconfig.json | 10 +++++++ packages/trpc/package.json | 1 + packages/trpc/src/procedures/auth.ts | 4 +++ tests/api/email.test.ts | 9 ++++++ 11 files changed, 133 insertions(+) create mode 100644 apps/web/.env.example create mode 100644 packages/email/eslint.config.js create mode 100644 packages/email/package.json create mode 100644 packages/email/src/index.ts create mode 100644 packages/email/src/test.ts create mode 100644 packages/email/tsconfig.json create mode 100644 tests/api/email.test.ts diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..2108048 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1 @@ +SENDGRID_API_KEY='' \ No newline at end of file diff --git a/apps/web/env.js b/apps/web/env.js index 5ef05cb..a772049 100644 --- a/apps/web/env.js +++ b/apps/web/env.js @@ -11,6 +11,7 @@ export const env = createEnv({ NODE_ENV: z .enum(["development", "test", "production"]) .default("development"), + SENDGRID_API_KEY: z.string(), }, /** @@ -28,6 +29,7 @@ export const env = createEnv({ */ runtimeEnv: { NODE_ENV: process.env.NODE_ENV, + SENDGRID_API_KEY: process.env.SENDGRID_API_KEY, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/bun.lockb b/bun.lockb index 8dbbae65bb085d862fe3f60c5d453755fed6461f..bef065b7320eaef68c8ae3b861d51189021b0a9b 100755 GIT binary patch delta 44187 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�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 { + // TODO: Implement this later? + // sgMail.setApiKey(env.SENDGRID_API_KEY); + + // This code is just for using dotenv, should be removed later + dotenv.config({ path: "../.env" }); + const sendGridAPIKey = process.env.SENDGRID_API_KEY; + if (sendGridAPIKey === undefined) { + console.log("Undefined"); + return false; + } + sgMail.setApiKey(sendGridAPIKey); + // End here + + const msg = { + to: toEmail, + from: "jordanpraissman@gmail.com", + subject: "Verify Your Email - Good Dog Licensing", + html: `

Your Verification Code: ${code}

`, + }; + + try { + await sgMail.send(msg); + } catch (error) { + console.error(error); + return false; + } + + return true; +} + +void sendEmailVerification("jordanpraissman@gmail.com", "123456"); diff --git a/packages/email/src/test.ts b/packages/email/src/test.ts new file mode 100644 index 0000000..3ea9987 --- /dev/null +++ b/packages/email/src/test.ts @@ -0,0 +1,28 @@ +import sgMail from "@sendgrid/mail"; +import dotenv from "dotenv"; + +const result = dotenv.config({ path: "../.env" }); +console.log(result); + +const sendGridAPIKey = process.env.SENDGRID_API_KEY; + +if (sendGridAPIKey === undefined) { + console.log("Here"); +} else { + sgMail.setApiKey(sendGridAPIKey); + const msg = { + to: "jordanpraissman@gmail.com", // Change to your recipient + from: "jordanpraissman@gmail.com", // Change to your verified sender + subject: "Sending with SendGrid is Fun", + text: "and easy to do anywhere, even with Node.js", + html: "and easy to do anywhere, even with Node.js", + }; + sgMail + .send(msg) + .then(() => { + console.log("Email sent"); + }) + .catch((error) => { + console.error(error); + }); +} diff --git a/packages/email/tsconfig.json b/packages/email/tsconfig.json new file mode 100644 index 0000000..39f0d3b --- /dev/null +++ b/packages/email/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@good-dog/typescript/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + "lib": ["es2022"], + "types": ["bun"] + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/packages/trpc/package.json b/packages/trpc/package.json index b7c7a1a..09a6d11 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -18,6 +18,7 @@ "devDependencies": { "@good-dog/auth": "workspace:*", "@good-dog/db": "workspace:*", + "@good-dog/email": "workspace:*", "@good-dog/eslint": "workspace:*", "@good-dog/prettier": "workspace:*", "@good-dog/typescript": "workspace:*", diff --git a/packages/trpc/src/procedures/auth.ts b/packages/trpc/src/procedures/auth.ts index c2485e9..47a58c5 100644 --- a/packages/trpc/src/procedures/auth.ts +++ b/packages/trpc/src/procedures/auth.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { comparePassword, hashPassword } from "@good-dog/auth"; +import { sendEmailVerification } from "@good-dog/email"; import { baseProcedureBuilder } from "../internal/init"; @@ -43,6 +44,9 @@ export const signUpProcedure = baseProcedureBuilder }, }); + // THINK ABOUT PROPER WAY TO HANDLE ERRORS HERE (or handle in the email function) + await sendEmailVerification(user.email, "123456"); + return { message: `Successfully signed up and logged in as ${input.email}`, sessionId: session.id, diff --git a/tests/api/email.test.ts b/tests/api/email.test.ts new file mode 100644 index 0000000..ea6c322 --- /dev/null +++ b/tests/api/email.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "bun:test"; + +import { sendEmailVerification } from "../../packages/email/src/index"; + +test("Email is sent correctly.", () => { + expect(sendEmailVerification("jordanpraissman@gmail.com", "123456")).toEqual( + true, + ); +}); From 2d2b04b4b49d233b3c98cb632368346a02418c5e Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Wed, 30 Oct 2024 19:35:12 -0400 Subject: [PATCH 02/17] Saving changes --- apps/db/prisma/schema.prisma | 9 ++++ packages/email/src/test.ts | 3 +- packages/trpc/src/internal/init.ts | 2 + packages/trpc/src/procedures/auth.ts | 62 ++++++++++++++++++++++++++-- tests/api/email.test.ts | 10 ++--- 5 files changed, 76 insertions(+), 10 deletions(-) diff --git a/apps/db/prisma/schema.prisma b/apps/db/prisma/schema.prisma index 814b2bf..ef47758 100644 --- a/apps/db/prisma/schema.prisma +++ b/apps/db/prisma/schema.prisma @@ -20,6 +20,8 @@ model User { name String hashedPassword String @map("password") sessions Session[] + emailVerificationCode EmailVerificationCode? + confirmedEmail Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -31,3 +33,10 @@ model Session { createdAt DateTime @default(now()) expiresAt DateTime } + +model EmailVerificationCode { + userId String @id @unique + code String + user User @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) +} diff --git a/packages/email/src/test.ts b/packages/email/src/test.ts index 3ea9987..a01d219 100644 --- a/packages/email/src/test.ts +++ b/packages/email/src/test.ts @@ -1,8 +1,7 @@ import sgMail from "@sendgrid/mail"; import dotenv from "dotenv"; -const result = dotenv.config({ path: "../.env" }); -console.log(result); +dotenv.config({ path: "../.env" }); const sendGridAPIKey = process.env.SENDGRID_API_KEY; diff --git a/packages/trpc/src/internal/init.ts b/packages/trpc/src/internal/init.ts index 0308eec..a50ed75 100644 --- a/packages/trpc/src/internal/init.ts +++ b/packages/trpc/src/internal/init.ts @@ -68,6 +68,8 @@ export const authenticatedProcedureBuilder = baseProcedureBuilder.use( email: true, name: true, sessions: true, + emailVerificationCode: true, + confirmedEmail: true, createdAt: true, updatedAt: true, }, diff --git a/packages/trpc/src/procedures/auth.ts b/packages/trpc/src/procedures/auth.ts index 6e64e35..d7d0eba 100644 --- a/packages/trpc/src/procedures/auth.ts +++ b/packages/trpc/src/procedures/auth.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { deleteSessionCookie, setSessionCookie } from "@good-dog/auth/cookies"; import { comparePassword, hashPassword } from "@good-dog/auth/password"; +import { sendEmailVerification } from "@good-dog/email"; import { authenticatedProcedureBuilder, @@ -34,6 +35,22 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder }); } + // DO WE WANT THE USER TO BE CREATED IF THE EMAIL FAILS? OR FAIL BEFORE USER IS CREATED? + + // Generate 6 digit code for email verification + let emailCode = ""; + for (let i = 0; i < 6; i++) { + emailCode += Math.floor(Math.random() * 10); + } + + if (!(await sendEmailVerification(input.email, emailCode))) { + // Email failed to send + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Email confirmation to ${input.email} failed to send.`, + }); + } + const hashedPassword = await hashPassword(input.password); const userWithSession = await ctx.prisma.user.create({ @@ -46,6 +63,11 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder expiresAt: getNewSessionExpirationDate(), }, }, + emailVerificationCode: { + create: { + code: emailCode, + }, + }, }, select: { sessions: true, @@ -63,11 +85,45 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder setSessionCookie(session.id, session.expiresAt); - // THINK ABOUT PROPER WAY TO HANDLE ERRORS HERE (or handle in the email function) - await sendEmailVerification(user.email, "123456"); + return { + message: `Successfully signed up as ${input.email}. User's email must still be verified.`, + }; + }); + +export const confirmEmailProcedure = authenticatedProcedureBuilder + .input( + z.object({ + code: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + // Need to first check if there is an emailVerificationCode, if not throw error? + if (ctx.session.user.emailVerificationCode == null) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "User does not have an assiocated email verification code or the user has already verifed email.", + }); + } + + const expectedCode = ctx.session.user.emailVerificationCode.code; + if (expectedCode != input.code) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "User does not have an assiocated email verification code or the user has already verifed email.", + }); + } + + // If here, the given code was correct, so we can delete the emailVerificationCode. + await ctx.prisma.emailVerificationCode.delete({ + where: { + userId: ctx.session.userId, + }, + }); return { - message: `Successfully signed up and logged in as ${input.email}`, + message: `Email was successfully verified. Email: ${ctx.session.user.email}.`, }; }); diff --git a/tests/api/email.test.ts b/tests/api/email.test.ts index ea6c322..200b03f 100644 --- a/tests/api/email.test.ts +++ b/tests/api/email.test.ts @@ -2,8 +2,8 @@ import { expect, test } from "bun:test"; import { sendEmailVerification } from "../../packages/email/src/index"; -test("Email is sent correctly.", () => { - expect(sendEmailVerification("jordanpraissman@gmail.com", "123456")).toEqual( - true, - ); -}); +// test("Email is sent correctly.", () => { +// expect(sendEmailVerification("jordanpraissman@gmail.com", "123456")).toEqual( +// true, +// ); +// }); From 54fb2e76f4476721a8ac06a081cae324e97024d1 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Wed, 30 Oct 2024 20:16:01 -0400 Subject: [PATCH 03/17] Started working on new produecrues --- packages/db/prisma/schema.prisma | 11 +- packages/trpc/src/procedures/auth.ts | 145 ++++++++++++++++----------- 2 files changed, 92 insertions(+), 64 deletions(-) diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index ef47758..a2e2f56 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -20,8 +20,6 @@ model User { name String hashedPassword String @map("password") sessions Session[] - emailVerificationCode EmailVerificationCode? - confirmedEmail Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -35,8 +33,9 @@ model Session { } model EmailVerificationCode { - userId String @id @unique - code String - user User @relation(fields: [userId], references: [id]) - createdAt DateTime @default(now()) + email String @id @unique + code String + emailConfirmed Boolean @default(false) + createdAt DateTime @default(now()) + expiresAt DateTime } diff --git a/packages/trpc/src/procedures/auth.ts b/packages/trpc/src/procedures/auth.ts index d7d0eba..71199f2 100644 --- a/packages/trpc/src/procedures/auth.ts +++ b/packages/trpc/src/procedures/auth.ts @@ -13,6 +13,93 @@ import { const getNewSessionExpirationDate = () => new Date(Date.now() + 60_000 * 60 * 24 * 30); +const getNewEmailVerificationCodeExpirationDate = () => + new Date(Date.now() + 60_000 * 15); + +export const sendEmailVerificationProcedure = notAuthenticatedProcedureBuilder + .input( + z.object({ + email: z.string().email(), + }), + ) + .mutation(async ({ ctx, input }) => { + // Generate 6 digit code for email verification + let emailCode = ""; + for (let i = 0; i < 6; i++) { + emailCode += Math.floor(Math.random() * 10); + } + + // Send email. If sending fails, throw error. + if (!(await sendEmailVerification(input.email, emailCode))) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Email confirmation to ${input.email} failed to send.`, + }); + } + + // Create the email verification code in the database + await ctx.prisma.emailVerificationCode.create({ + data: { + code: emailCode, + email: input.email, + expiresAt: getNewEmailVerificationCodeExpirationDate(), + }, + }); + + return { + message: `Email verification code sent to ${input.email}`, + }; + }); + +export const confirmEmailProcedure = notAuthenticatedProcedureBuilder + .input( + z.object({ + email: z.string().email(), + code: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + // Get email verification from database + const emailVerificationCode = + await ctx.prisma.emailVerificationCode.findUnique({ + where: { + email: input.email, + }, + }); + + // If email verification not found, throw error + if (emailVerificationCode == null) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `${input.email} is not waiting to be confirmed.`, + }); + } + + // TODO: Check email verification code is not expired. + + // If given code is wrong, throw error + if (input.code != emailVerificationCode.code) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Given code is incorrect for ${input.email}`, + }); + } + + // If here, the given code was correct, so we can delete the emailVerificationCode. + await ctx.prisma.emailVerificationCode.update({ + where: { + email: input.email, + }, + data: { + emailConfirmed: true, + }, + }); + + return { + message: `Email was successfully verified. Email: ${ctx.session.user.email}.`, + }; + }); + export const signUpProcedure = notAuthenticatedProcedureBuilder .input( z.object({ @@ -35,22 +122,6 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder }); } - // DO WE WANT THE USER TO BE CREATED IF THE EMAIL FAILS? OR FAIL BEFORE USER IS CREATED? - - // Generate 6 digit code for email verification - let emailCode = ""; - for (let i = 0; i < 6; i++) { - emailCode += Math.floor(Math.random() * 10); - } - - if (!(await sendEmailVerification(input.email, emailCode))) { - // Email failed to send - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Email confirmation to ${input.email} failed to send.`, - }); - } - const hashedPassword = await hashPassword(input.password); const userWithSession = await ctx.prisma.user.create({ @@ -63,11 +134,6 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder expiresAt: getNewSessionExpirationDate(), }, }, - emailVerificationCode: { - create: { - code: emailCode, - }, - }, }, select: { sessions: true, @@ -90,43 +156,6 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder }; }); -export const confirmEmailProcedure = authenticatedProcedureBuilder - .input( - z.object({ - code: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - // Need to first check if there is an emailVerificationCode, if not throw error? - if (ctx.session.user.emailVerificationCode == null) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: - "User does not have an assiocated email verification code or the user has already verifed email.", - }); - } - - const expectedCode = ctx.session.user.emailVerificationCode.code; - if (expectedCode != input.code) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: - "User does not have an assiocated email verification code or the user has already verifed email.", - }); - } - - // If here, the given code was correct, so we can delete the emailVerificationCode. - await ctx.prisma.emailVerificationCode.delete({ - where: { - userId: ctx.session.userId, - }, - }); - - return { - message: `Email was successfully verified. Email: ${ctx.session.user.email}.`, - }; - }); - export const signInProcedure = notAuthenticatedProcedureBuilder .input( z.object({ From 1005f6a80d7849b266be4ea8166a5080d0c0b78b Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sat, 2 Nov 2024 13:40:30 -0400 Subject: [PATCH 04/17] Finished send email procedure and confirm email procedure --- packages/trpc/src/procedures/auth.ts | 48 ++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/trpc/src/procedures/auth.ts b/packages/trpc/src/procedures/auth.ts index 71199f2..c57dc78 100644 --- a/packages/trpc/src/procedures/auth.ts +++ b/packages/trpc/src/procedures/auth.ts @@ -23,20 +23,43 @@ export const sendEmailVerificationProcedure = notAuthenticatedProcedureBuilder }), ) .mutation(async ({ ctx, input }) => { + // Check if there is already an email verification code for the given email + const existingEmailVerificationCode = + await ctx.prisma.emailVerificationCode.findUnique({ + where: { + email: input.email, + }, + }); + // If email already verified, throw error + if (existingEmailVerificationCode?.emailConfirmed) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Email already verified.", + cause: "Email already verified", + }); + } + // If email not verified, delete current email verification code to create a new one + if (existingEmailVerificationCode !== null) { + await ctx.prisma.emailVerificationCode.delete({ + where: { + email: input.email, + }, + }); + } + // Generate 6 digit code for email verification let emailCode = ""; for (let i = 0; i < 6; i++) { emailCode += Math.floor(Math.random() * 10); } - // Send email. If sending fails, throw error. if (!(await sendEmailVerification(input.email, emailCode))) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Email confirmation to ${input.email} failed to send.`, + cause: "Email send failure", }); } - // Create the email verification code in the database await ctx.prisma.emailVerificationCode.create({ data: { @@ -68,24 +91,31 @@ export const confirmEmailProcedure = notAuthenticatedProcedureBuilder }); // If email verification not found, throw error - if (emailVerificationCode == null) { + if (emailVerificationCode === null) { throw new TRPCError({ code: "NOT_FOUND", message: `${input.email} is not waiting to be confirmed.`, + cause: "Email not found", }); } - - // TODO: Check email verification code is not expired. - // If given code is wrong, throw error - if (input.code != emailVerificationCode.code) { + if (input.code !== emailVerificationCode.code) { throw new TRPCError({ code: "BAD_REQUEST", message: `Given code is incorrect for ${input.email}`, + cause: "Incorrect code", + }); + } + // If given code is expired, throw error + if (emailVerificationCode.expiresAt < new Date()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Given code is expired.", + cause: "Code expired", }); } - // If here, the given code was correct, so we can delete the emailVerificationCode. + // If here, the given code was valid, so we can update the emailVerificationCode. await ctx.prisma.emailVerificationCode.update({ where: { email: input.email, @@ -96,7 +126,7 @@ export const confirmEmailProcedure = notAuthenticatedProcedureBuilder }); return { - message: `Email was successfully verified. Email: ${ctx.session.user.email}.`, + message: `Email was successfully verified. Email: ${input.email}.`, }; }); From 257d63c0b427540e7f0b0052550ba498139e0545 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sat, 2 Nov 2024 14:13:37 -0400 Subject: [PATCH 05/17] Updated sign up prodecure to make sure email was verified --- packages/trpc/src/internal/init.ts | 2 -- packages/trpc/src/procedures/auth.ts | 23 ++++++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/trpc/src/internal/init.ts b/packages/trpc/src/internal/init.ts index 9a96633..bbd6600 100644 --- a/packages/trpc/src/internal/init.ts +++ b/packages/trpc/src/internal/init.ts @@ -69,8 +69,6 @@ export const authenticatedProcedureBuilder = baseProcedureBuilder.use( email: true, name: true, sessions: true, - emailVerificationCode: true, - confirmedEmail: true, createdAt: true, updatedAt: true, }, diff --git a/packages/trpc/src/procedures/auth.ts b/packages/trpc/src/procedures/auth.ts index c57dc78..87e9078 100644 --- a/packages/trpc/src/procedures/auth.ts +++ b/packages/trpc/src/procedures/auth.ts @@ -90,6 +90,13 @@ export const confirmEmailProcedure = notAuthenticatedProcedureBuilder }, }); + // If email already verified, return a success + if (emailVerificationCode?.emailConfirmed) { + return { + message: `Email was successfully verified. Email: ${input.email}.`, + }; + } + // If email verification not found, throw error if (emailVerificationCode === null) { throw new TRPCError({ @@ -139,6 +146,20 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder }), ) .mutation(async ({ ctx, input }) => { + // Throw error if email is not verified + const emailVerificationCode = + await ctx.prisma.emailVerificationCode.findUnique({ + where: { + email: input.email, + }, + }); + if (!emailVerificationCode?.emailConfirmed) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Email has not been verified.", + }); + } + const existingUserWithEmail = await ctx.prisma.user.findUnique({ where: { email: input.email, @@ -182,7 +203,7 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder setSessionCookie(session.id, session.expiresAt); return { - message: `Successfully signed up as ${input.email}. User's email must still be verified.`, + message: `Successfully signed up as ${input.email}.`, }; }); From bb5c70308ae668a822371499dcba6c2f2d3ab3eb Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sat, 2 Nov 2024 14:37:21 -0400 Subject: [PATCH 06/17] Updated env to include from email and the sendgrid api key --- .env.example | 6 +++++- packages/email/src/index.ts | 21 +++------------------ packages/env/src/env.js | 14 +++----------- tests/api/email.test.ts | 8 ++++---- turbo.json | 4 +++- 5 files changed, 18 insertions(+), 35 deletions(-) diff --git a/.env.example b/.env.example index 4348d42..266ee0a 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ POSTGRES_PORT=5432 DATABASE_PRISMA_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DATABASE}" +SENDGRID_API_KEY="" +VERIFICATION_FROM_EMAIL="example@gmail.com" # Windows Version: # Strings must be in double quotes on Windows, and template strings cannot be used @@ -15,4 +17,6 @@ DATABASE_PRISMA_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhos # POSTGRES_DATABASE="good-dog" # POSTGRES_PORT=5432 -# DATABASE_PRISMA_URL="postgresql://user:password@localhost:5432/good-dog" \ No newline at end of file +# DATABASE_PRISMA_URL="postgresql://user:password@localhost:5432/good-dog" + +# SENDGRID_API_KEY="" \ No newline at end of file diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts index 4cbb4de..035a42c 100644 --- a/packages/email/src/index.ts +++ b/packages/email/src/index.ts @@ -1,29 +1,16 @@ import sgMail from "@sendgrid/mail"; -import dotenv from "dotenv"; -// TODO: Implement this later? -// import { env } from "../../../apps/web/env"; +import { env } from "../../env/src/env"; export async function sendEmailVerification( toEmail: string, code: string, ): Promise { - // TODO: Implement this later? - // sgMail.setApiKey(env.SENDGRID_API_KEY); - - // This code is just for using dotenv, should be removed later - dotenv.config({ path: "../.env" }); - const sendGridAPIKey = process.env.SENDGRID_API_KEY; - if (sendGridAPIKey === undefined) { - console.log("Undefined"); - return false; - } - sgMail.setApiKey(sendGridAPIKey); - // End here + sgMail.setApiKey(env.SENDGRID_API_KEY); const msg = { to: toEmail, - from: "jordanpraissman@gmail.com", + from: env.VERIFICATION_FROM_EMAIL, subject: "Verify Your Email - Good Dog Licensing", html: `

Your Verification Code: ${code}

`, }; @@ -37,5 +24,3 @@ export async function sendEmailVerification( return true; } - -void sendEmailVerification("jordanpraissman@gmail.com", "123456"); diff --git a/packages/env/src/env.js b/packages/env/src/env.js index b62d574..b785dae 100644 --- a/packages/env/src/env.js +++ b/packages/env/src/env.js @@ -10,6 +10,8 @@ export const env = createEnv({ */ runtimeEnv: { NODE_ENV: process.env.NODE_ENV, + SENDGRID_API_KEY: process.env.SENDGRID_API_KEY, + VERIFICATION_FROM_EMAIL: process.env.VERIFICATION_FROM_EMAIL, }, /** @@ -21,6 +23,7 @@ export const env = createEnv({ .enum(["development", "test", "production"]) .default("development"), SENDGRID_API_KEY: z.string(), + VERIFICATION_FROM_EMAIL: z.string().email(), }, /** @@ -31,18 +34,7 @@ export const env = createEnv({ client: { // NEXT_PUBLIC_CLIENTVAR: z.string(), }, -<<<<<<< HEAD:apps/web/env.js - /** - * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. - * middlewares) or client-side so we need to destruct manually. - */ - runtimeEnv: { - NODE_ENV: process.env.NODE_ENV, - SENDGRID_API_KEY: process.env.SENDGRID_API_KEY, - }, -======= ->>>>>>> main:packages/env/src/env.js /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially * useful for Docker builds. diff --git a/tests/api/email.test.ts b/tests/api/email.test.ts index 200b03f..05fdc55 100644 --- a/tests/api/email.test.ts +++ b/tests/api/email.test.ts @@ -2,8 +2,8 @@ import { expect, test } from "bun:test"; import { sendEmailVerification } from "../../packages/email/src/index"; -// test("Email is sent correctly.", () => { -// expect(sendEmailVerification("jordanpraissman@gmail.com", "123456")).toEqual( -// true, -// ); +// test("Email is sent correctly.", async () => { +// expect( +// await sendEmailVerification("jordanpraissman@gmail.com", "123456"), +// ).toEqual(true); // }); diff --git a/turbo.json b/turbo.json index 3b33cd5..436aaed 100644 --- a/turbo.json +++ b/turbo.json @@ -86,6 +86,8 @@ "NODE_ENV", "VERCEL_ENV", "VERCEL_URL", - "DATABASE_PRISMA_URL" + "DATABASE_PRISMA_URL", + "SENDGRID_API_KEY", + "VERIFICATION_FROM_EMAIL" ] } From 97bb5f394ace00f86d6192470da0c7ae4dde133e Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sat, 2 Nov 2024 16:01:59 -0400 Subject: [PATCH 07/17] Created db migration --- .../migration.sql | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 packages/db/prisma/migrations/20241102200137_email_verification_code/migration.sql diff --git a/packages/db/prisma/migrations/20241102200137_email_verification_code/migration.sql b/packages/db/prisma/migrations/20241102200137_email_verification_code/migration.sql new file mode 100644 index 0000000..547b8f7 --- /dev/null +++ b/packages/db/prisma/migrations/20241102200137_email_verification_code/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "EmailVerificationCode" ( + "email" TEXT NOT NULL, + "code" TEXT NOT NULL, + "emailConfirmed" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EmailVerificationCode_pkey" PRIMARY KEY ("email") +); + +-- CreateIndex +CREATE UNIQUE INDEX "EmailVerificationCode_email_key" ON "EmailVerificationCode"("email"); From 70dc2bf56eec94bdcf6c23328c83e3983bfd38cc Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sun, 3 Nov 2024 16:26:43 -0500 Subject: [PATCH 08/17] Finished tests --- packages/email/src/test.ts | 27 --- packages/trpc/src/internal/router.ts | 4 + packages/trpc/src/procedures/auth.ts | 10 +- tests/api/auth.test.ts | 316 +++++++++++++++++++++++---- tests/mocks/MockSendGridEmails.ts | 19 ++ 5 files changed, 303 insertions(+), 73 deletions(-) delete mode 100644 packages/email/src/test.ts create mode 100644 tests/mocks/MockSendGridEmails.ts diff --git a/packages/email/src/test.ts b/packages/email/src/test.ts deleted file mode 100644 index a01d219..0000000 --- a/packages/email/src/test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import sgMail from "@sendgrid/mail"; -import dotenv from "dotenv"; - -dotenv.config({ path: "../.env" }); - -const sendGridAPIKey = process.env.SENDGRID_API_KEY; - -if (sendGridAPIKey === undefined) { - console.log("Here"); -} else { - sgMail.setApiKey(sendGridAPIKey); - const msg = { - to: "jordanpraissman@gmail.com", // Change to your recipient - from: "jordanpraissman@gmail.com", // Change to your verified sender - subject: "Sending with SendGrid is Fun", - text: "and easy to do anywhere, even with Node.js", - html: "and easy to do anywhere, even with Node.js", - }; - sgMail - .send(msg) - .then(() => { - console.log("Email sent"); - }) - .catch((error) => { - console.error(error); - }); -} diff --git a/packages/trpc/src/internal/router.ts b/packages/trpc/src/internal/router.ts index 5402b77..c7d5ac3 100644 --- a/packages/trpc/src/internal/router.ts +++ b/packages/trpc/src/internal/router.ts @@ -1,5 +1,7 @@ import { + confirmEmailProcedure, deleteAccountProcedure, + sendEmailVerificationProcedure, signInProcedure, signOutProcedure, signUpProcedure, @@ -8,6 +10,8 @@ import { getAuthenticatedUserProcedure } from "../procedures/user"; import { createTRPCRouter } from "./init"; export const appRouter = createTRPCRouter({ + sendEmailVerification: sendEmailVerificationProcedure, + confirmEmail: confirmEmailProcedure, signIn: signInProcedure, signOut: signOutProcedure, signUp: signUpProcedure, diff --git a/packages/trpc/src/procedures/auth.ts b/packages/trpc/src/procedures/auth.ts index 87e9078..f1af3c9 100644 --- a/packages/trpc/src/procedures/auth.ts +++ b/packages/trpc/src/procedures/auth.ts @@ -34,8 +34,7 @@ export const sendEmailVerificationProcedure = notAuthenticatedProcedureBuilder if (existingEmailVerificationCode?.emailConfirmed) { throw new TRPCError({ code: "BAD_REQUEST", - message: "Email already verified.", - cause: "Email already verified", + message: "Email already verified", }); } // If email not verified, delete current email verification code to create a new one @@ -57,7 +56,6 @@ export const sendEmailVerificationProcedure = notAuthenticatedProcedureBuilder throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Email confirmation to ${input.email} failed to send.`, - cause: "Email send failure", }); } // Create the email verification code in the database @@ -102,7 +100,6 @@ export const confirmEmailProcedure = notAuthenticatedProcedureBuilder throw new TRPCError({ code: "NOT_FOUND", message: `${input.email} is not waiting to be confirmed.`, - cause: "Email not found", }); } // If given code is wrong, throw error @@ -110,7 +107,6 @@ export const confirmEmailProcedure = notAuthenticatedProcedureBuilder throw new TRPCError({ code: "BAD_REQUEST", message: `Given code is incorrect for ${input.email}`, - cause: "Incorrect code", }); } // If given code is expired, throw error @@ -118,7 +114,6 @@ export const confirmEmailProcedure = notAuthenticatedProcedureBuilder throw new TRPCError({ code: "BAD_REQUEST", message: "Given code is expired.", - cause: "Code expired", }); } @@ -153,6 +148,7 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder email: input.email, }, }); + if (!emailVerificationCode?.emailConfirmed) { throw new TRPCError({ code: "FORBIDDEN", @@ -203,7 +199,7 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder setSessionCookie(session.id, session.expiresAt); return { - message: `Successfully signed up as ${input.email}.`, + message: `Successfully signed up and logged in as ${input.email}.`, }; }); diff --git a/tests/api/auth.test.ts b/tests/api/auth.test.ts index 09a1ece..95aa984 100644 --- a/tests/api/auth.test.ts +++ b/tests/api/auth.test.ts @@ -1,20 +1,33 @@ -import { - afterAll, - afterEach, - beforeAll, - describe, - expect, - test, -} from "bun:test"; +import { afterEach, beforeAll, describe, expect, test } from "bun:test"; import { hashPassword } from "@good-dog/auth/password"; import { prisma } from "@good-dog/db"; import { $trpcCaller } from "@good-dog/trpc/server"; import { MockNextCookies } from "../mocks/MockNextCookies"; +import { MockSendGridEmails } from "../mocks/MockSendGridEmails"; describe("auth", () => { const mockCookies = new MockNextCookies(); + const mockEmails = new MockSendGridEmails(); + + const createEmailVerificationCode = async (emailConfirmed: boolean) => + prisma.emailVerificationCode.upsert({ + create: { + email: "damian@gmail.com", + code: "019821", + expiresAt: new Date(Date.now() + 60_000 * 100000), + emailConfirmed: emailConfirmed, + }, + update: { + code: "019821", + expiresAt: new Date(Date.now() + 60_000 * 100000), + emailConfirmed: emailConfirmed, + }, + where: { + email: "damian@gmail.com", + }, + }); const createAccount = async () => prisma.user.upsert({ @@ -55,52 +68,259 @@ describe("auth", () => { }); const cleanupAccount = async () => { - await prisma.user.delete({ - where: { - email: "damian@gmail.com", - }, - }); + try { + await prisma.user.delete({ + where: { + email: "damian@gmail.com", + }, + }); + } catch (ignored) {} + }; + + const cleanupEmailVerificationCode = async () => { + try { + await prisma.emailVerificationCode.delete({ + where: { + email: "damian@gmail.com", + }, + }); + } catch (ignored) {} }; beforeAll(async () => { await mockCookies.apply(); + await mockEmails.apply(); }); afterEach(() => { mockCookies.clear(); }); - test("auth/signUp", async () => { - const response = await $trpcCaller.signUp({ - name: "Damian", - email: "damian@gmail.com", - password: "password123", + describe("auth/sendEmailVerification", () => { + test("Email is not in database", async () => { + await cleanupEmailVerificationCode(); + + const response = await $trpcCaller.sendEmailVerification({ + email: "damian@gmail.com", + }); + + const emailVerificationCode = + await prisma.emailVerificationCode.findUnique({ + where: { + email: "damian@gmail.com", + }, + }); + + expect(emailVerificationCode?.email).toEqual("damian@gmail.com"); + expect(emailVerificationCode?.code.length).toEqual(6); + expect(emailVerificationCode?.emailConfirmed).toEqual(false); + + expect(response.message).toEqual( + "Email verification code sent to damian@gmail.com", + ); + + await cleanupEmailVerificationCode(); }); - expect(response.message).toEqual( - "Successfully signed up and logged in as damian@gmail.com", - ); + test("Email is already in database (not verified)", async () => { + await createEmailVerificationCode(false); - expect(mockCookies.set).toBeCalledWith("sessionId", expect.any(String), { - httpOnly: true, - secure: true, - sameSite: "lax", - path: "/", - expires: expect.any(Date), + const response = await $trpcCaller.sendEmailVerification({ + email: "damian@gmail.com", + }); + + const emailVerificationCode = + await prisma.emailVerificationCode.findUnique({ + where: { + email: "damian@gmail.com", + }, + }); + + expect(emailVerificationCode?.email).toEqual("damian@gmail.com"); + expect(emailVerificationCode?.code.length).toEqual(6); + expect(emailVerificationCode?.code).not.toEqual("019821"); + expect(emailVerificationCode?.emailConfirmed).toEqual(false); + + expect(response.message).toEqual( + "Email verification code sent to damian@gmail.com", + ); + + await cleanupEmailVerificationCode(); }); - await cleanupAccount(); + test("Email is already in database (verified)", async () => { + await createEmailVerificationCode(true); + + const sendEmailVerification = async () => + $trpcCaller.sendEmailVerification({ + email: "damian@gmail.com", + }); + + expect(sendEmailVerification).toThrow("Email already verified"); + + await cleanupEmailVerificationCode(); + }); }); - describe("with existing account", () => { - beforeAll(async () => { - await createAccount(); + describe("auth/confirmEmail", () => { + test("Email already verified", async () => { + await createEmailVerificationCode(true); + + const response = await $trpcCaller.confirmEmail({ + email: "damian@gmail.com", + code: "019821", + }); + + expect(response.message).toEqual( + "Email was successfully verified. Email: damian@gmail.com.", + ); + + await cleanupEmailVerificationCode(); + }); + + test("No email verification code entry", async () => { + await cleanupEmailVerificationCode(); + + const confirmEmail = async () => + $trpcCaller.confirmEmail({ + email: "damian@gmail.com", + code: "123456", + }); + + expect(confirmEmail).toThrow( + "damian@gmail.com is not waiting to be confirmed.", + ); + }); + + test("Given code is incorrect", async () => { + await createEmailVerificationCode(false); + + const confirmEmail = async () => + $trpcCaller.confirmEmail({ + email: "damian@gmail.com", + code: "123456", + }); + + expect(confirmEmail).toThrow( + "Given code is incorrect for damian@gmail.com", + ); + + await cleanupEmailVerificationCode(); + }); + + test("Given code is expired", async () => { + await createEmailVerificationCode(false); + + await prisma.emailVerificationCode.update({ + where: { + email: "damian@gmail.com", + }, + data: { + expiresAt: new Date(Date.now() + 60_000 * -15), + }, + }); + + const confirmEmail = async () => + $trpcCaller.confirmEmail({ + email: "damian@gmail.com", + code: "019821", + }); + + expect(confirmEmail).toThrow("Given code is expired."); + + await cleanupEmailVerificationCode(); + }); + + test("Given code is valid", async () => { + await createEmailVerificationCode(false); + + const response = await $trpcCaller.confirmEmail({ + email: "damian@gmail.com", + code: "019821", + }); + + const emailVerificationCode = + await prisma.emailVerificationCode.findUnique({ + where: { + email: "damian@gmail.com", + }, + }); + + expect(emailVerificationCode?.emailConfirmed).toBe(true); + + expect(response.message).toEqual( + "Email was successfully verified. Email: damian@gmail.com.", + ); + + await cleanupEmailVerificationCode(); }); - afterAll(async () => { + }); + + describe("auth/signUp", () => { + test("Email is verified", async () => { + await createEmailVerificationCode(true); await cleanupAccount(); + + const response = await $trpcCaller.signUp({ + name: "Damian", + email: "damian@gmail.com", + password: "password123", + }); + + expect(response.message).toEqual( + "Successfully signed up and logged in as damian@gmail.com.", + ); + + expect(mockCookies.set).toBeCalledWith("sessionId", expect.any(String), { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + expires: expect.any(Date), + }); + + await cleanupAccount(); + await cleanupEmailVerificationCode(); + }); + + test("Email is not verified (awaiting)", async () => { + await createEmailVerificationCode(false); + await cleanupAccount(); + + const createAccountHelp = async () => + await $trpcCaller.signUp({ + name: "Damian", + email: "damian@gmail.com", + password: "password123", + }); + + expect(createAccountHelp).toThrow("Email has not been verified."); + + await cleanupEmailVerificationCode(); }); + test("Email is not verified (not awaiting)", async () => { + await cleanupEmailVerificationCode(); + await cleanupAccount(); + + const createAccountHelp = async () => + await $trpcCaller.signUp({ + name: "Damian", + email: "damian@gmail.com", + password: "password123", + }); + + expect(createAccountHelp).toThrow("Email has not been verified."); + + await cleanupEmailVerificationCode(); + }); + }); + + describe("with existing account", () => { test("auth/signIn", async () => { + await createEmailVerificationCode(true); + await createAccount(); + const signInResponse = await $trpcCaller.signIn({ email: "damian@gmail.com", password: "password123", @@ -116,9 +336,15 @@ describe("auth", () => { path: "/", expires: expect.any(Date), }); + + await cleanupEmailVerificationCode(); + await cleanupAccount(); }); - test("auth/signIn failure", () => { + test("auth/signIn failure", async () => { + await createEmailVerificationCode(true); + await createAccount(); + expect( $trpcCaller.signIn({ email: "damian@gmail.com", @@ -127,17 +353,29 @@ describe("auth", () => { ).rejects.toThrow("Invalid credentials"); expect(mockCookies.set).not.toBeCalled(); + + await cleanupEmailVerificationCode(); + await cleanupAccount(); }); - test("auth/signUp failure", () => { - expect( - $trpcCaller.signUp({ + test("auth/signUp failure", async () => { + await createEmailVerificationCode(true); + await createAccount(); + + const createAccountHelp = async () => + await $trpcCaller.signUp({ name: "Damian", email: "damian@gmail.com", - password: "password", - }), - ).rejects.toThrow("User already exists with email damian@gmail.com"); + password: "password123", + }); + + expect(createAccountHelp).toThrow( + "User already exists with email damian@gmail.com", + ); expect(mockCookies.set).not.toBeCalled(); + + await cleanupEmailVerificationCode(); + await cleanupAccount(); }); }); diff --git a/tests/mocks/MockSendGridEmails.ts b/tests/mocks/MockSendGridEmails.ts new file mode 100644 index 0000000..0f7f03d --- /dev/null +++ b/tests/mocks/MockSendGridEmails.ts @@ -0,0 +1,19 @@ +import { mock } from "bun:test"; + +// A mock for the cookies function in the NextJS next/header module. +export class MockSendGridEmails { + // Applies this mock to be the cookies used by the next/header module. This method + // must be called in order for this mock to be applied. + async apply(): Promise { + await mock.module("@sendgrid/mail", () => ({ + default: { + setApiKey: () => { + console.log("setting"); + }, + send: () => { + console.log("sending"); + }, + }, + })); + } +} From 39fe4137f62b20a3d5deb2062e61fe180e5f4366 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sun, 3 Nov 2024 16:30:51 -0500 Subject: [PATCH 09/17] Removed email testing file for now --- tests/api/email.test.ts | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 tests/api/email.test.ts diff --git a/tests/api/email.test.ts b/tests/api/email.test.ts deleted file mode 100644 index 05fdc55..0000000 --- a/tests/api/email.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { expect, test } from "bun:test"; - -import { sendEmailVerification } from "../../packages/email/src/index"; - -// test("Email is sent correctly.", async () => { -// expect( -// await sendEmailVerification("jordanpraissman@gmail.com", "123456"), -// ).toEqual(true); -// }); From 2595d9164a016756cd0e183dc210f81427c05192 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sun, 3 Nov 2024 16:39:06 -0500 Subject: [PATCH 10/17] Fixed linting --- tests/api/auth.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/api/auth.test.ts b/tests/api/auth.test.ts index 95aa984..eea4e4d 100644 --- a/tests/api/auth.test.ts +++ b/tests/api/auth.test.ts @@ -74,7 +74,9 @@ describe("auth", () => { email: "damian@gmail.com", }, }); - } catch (ignored) {} + } catch (error) { + console.log(error); + } }; const cleanupEmailVerificationCode = async () => { @@ -84,7 +86,9 @@ describe("auth", () => { email: "damian@gmail.com", }, }); - } catch (ignored) {} + } catch (error) { + console.log(error); + } }; beforeAll(async () => { From a5e3b7a3a6d6ae46032e3cf36db3e28c18dd242d Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sun, 3 Nov 2024 16:43:34 -0500 Subject: [PATCH 11/17] Fixed dependicies --- apps/web/.env.example | 1 - packages/email/package.json | 3 ++- packages/email/src/index.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 apps/web/.env.example diff --git a/apps/web/.env.example b/apps/web/.env.example deleted file mode 100644 index 2108048..0000000 --- a/apps/web/.env.example +++ /dev/null @@ -1 +0,0 @@ -SENDGRID_API_KEY='' \ No newline at end of file diff --git a/packages/email/package.json b/packages/email/package.json index ab98072..1df00ea 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -23,6 +23,7 @@ }, "prettier": "@good-dog/prettier", "dependencies": { - "@sendgrid/mail": "^8.1.4" + "@sendgrid/mail": "^8.1.4", + "@good-dog/env": "workspace:*" } } diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts index 035a42c..f4537d1 100644 --- a/packages/email/src/index.ts +++ b/packages/email/src/index.ts @@ -1,6 +1,6 @@ import sgMail from "@sendgrid/mail"; -import { env } from "../../env/src/env"; +import { env } from "@good-dog/env"; export async function sendEmailVerification( toEmail: string, From d502d7d62347931624e85caaaccc86ec3f8400bd Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sun, 3 Nov 2024 16:45:53 -0500 Subject: [PATCH 12/17] bun install --- bun.lockb | Bin 264380 -> 264412 bytes packages/email/package.json | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bun.lockb b/bun.lockb index 27414d9badc05d89b4371149aaa6192e2bd9a40a..9744a25450893a8f0787c8dcd2a657f62a25adae 100755 GIT binary patch delta 629 zcmdlpQQ*!*feCtwR_v7}#z#MPS_Eax`6Xk|Z>IdAocDBO)J4&c#%a09MLrw-Bd2j( z*L%4=`H*OKfSen|g`R7NL=?>q$HngzZy?QfTX5Krq1dghp zCpEd(=ZjSAz4k!*>Xh93ro6Vi*)OLzUk&AL+;{);1kalcPGNIy_dg6}U++@Pw|u*8 zC!_W+#>Lw+o0(=avVuZn>2}UmrsYhmD}d}}+qbqeRWq@ILSprH(=MiLMov&DtOeqA z(^qyg&1MC8e*JdO9;R|e#x>gy_A;#(V@hJ0p0|X_iu1t|P%ttu+?u{|36nY~=(b;9 z!c;nG`aL~nsqH@I%sGsl8kQh&28N932Q8Rgw{uuBJ1}w1u!acE-5zMoyti4daC+l?X3gmy`HrRq?`YJXS#pyekSrRxK xSRjH2wkxu*)H88@V1w`@rmtjU>E~L&4&iceOm|{uS;y(X0pa^h|H;mB7ywxKq!j=F delta 617 zcmcaJQDDzRfeCtwvWpv2m4%NrxSzXwQ4EX!y{*K((xl?sE`bSRV z2+CUC&wI`Cz~m1a5}Qv<+u|0-&cML1n3aKn2}mym(u_cQ8IT465DgMv0i>CM^lGR$ zhz9Z30cnu>^-ysT4dQQr@;9(<-gWYja4b#j*!=S&%XEjl8|x#ORvy+_Cf7XWRJ(cA zhdDD?C#CbRnqt0xp0ulmwMiE1g>}nMv;LfaaN6&Uy`fP`Q?nLw*-3a^zuIdptz*93 zwv$o&7vrMsnaxbI8CgLgvSd4FE7Ni&*5yF<((PN@nW~vsK_RhfyJ;6wHX|n}6xIOo z+UYC1nP#(sJil(cXAe_3Bjbwg2YZ>;i%l85nj<-@1fJefxtYOeK@H z=b1C-FmiHOf`XBO!DISk3uf2tYL?7SOq>qx42(<+ z4U8_kAbgAIC)rr~xf<9Z+!yTAlh|3-aVl^?_&U=iIam$@02oA|_5c6? diff --git a/packages/email/package.json b/packages/email/package.json index 1df00ea..7ef5ebc 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -23,7 +23,7 @@ }, "prettier": "@good-dog/prettier", "dependencies": { - "@sendgrid/mail": "^8.1.4", - "@good-dog/env": "workspace:*" + "@good-dog/env": "workspace:*", + "@sendgrid/mail": "^8.1.4" } } From ccb756f5b77a0ad90690d9666959e7fed3e56b15 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sun, 3 Nov 2024 16:49:58 -0500 Subject: [PATCH 13/17] Made env optional for testing --- packages/email/src/index.ts | 4 ++-- packages/env/src/env.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts index f4537d1..aad6760 100644 --- a/packages/email/src/index.ts +++ b/packages/email/src/index.ts @@ -6,11 +6,11 @@ export async function sendEmailVerification( toEmail: string, code: string, ): Promise { - sgMail.setApiKey(env.SENDGRID_API_KEY); + sgMail.setApiKey(env.SENDGRID_API_KEY ?? ""); const msg = { to: toEmail, - from: env.VERIFICATION_FROM_EMAIL, + from: env.VERIFICATION_FROM_EMAIL ?? "", subject: "Verify Your Email - Good Dog Licensing", html: `

Your Verification Code: ${code}

`, }; diff --git a/packages/env/src/env.js b/packages/env/src/env.js index b785dae..140a5fa 100644 --- a/packages/env/src/env.js +++ b/packages/env/src/env.js @@ -22,8 +22,8 @@ export const env = createEnv({ NODE_ENV: z .enum(["development", "test", "production"]) .default("development"), - SENDGRID_API_KEY: z.string(), - VERIFICATION_FROM_EMAIL: z.string().email(), + SENDGRID_API_KEY: z.string().optional(), + VERIFICATION_FROM_EMAIL: z.string().email().optional(), }, /** From c746a2bd26604ba123ff41ec4a26f32c4564a8c9 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Tue, 5 Nov 2024 14:15:12 -0500 Subject: [PATCH 14/17] Added email service module and updated tests --- bun.lockb | Bin 264412 -> 264436 bytes packages/email/src/email-service.ts | 39 ++++++++++++++++++ packages/email/src/index.ts | 15 ++----- packages/trpc/package.json | 1 + tests/api/auth.test.ts | 21 +++++++++- ...kSendGridEmails.ts => MockEmailService.ts} | 19 +++++++-- 6 files changed, 79 insertions(+), 16 deletions(-) create mode 100644 packages/email/src/email-service.ts rename tests/mocks/{MockSendGridEmails.ts => MockEmailService.ts} (53%) diff --git a/bun.lockb b/bun.lockb index 9744a25450893a8f0787c8dcd2a657f62a25adae..5b22ebee76f9fd8556cd38fcd2c8da6d441741b3 100755 GIT binary patch delta 2877 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 X&|nB>iRYpf)tGAtyGw<|p!j|OzWk;v diff --git a/packages/email/src/email-service.ts b/packages/email/src/email-service.ts new file mode 100644 index 0000000..0902b43 --- /dev/null +++ b/packages/email/src/email-service.ts @@ -0,0 +1,39 @@ +import sgMail from "@sendgrid/mail"; + +// These functions are an abstraction over the sgMail module from sendgrid. The main +// purpose is to throw runtime errors for blank api keys/from emails so we are alerted of +// the issue ahead of time. + +function setApiKey(apiKey: string) { + if (apiKey == "") { + throw new TypeError("Invalid api key: Expected a non-empty string."); + } + + sgMail.setApiKey(apiKey); +} + +interface EmailMessage { + to: string; + from: string; + subject: string; + html: string; +} + +async function send(msg: EmailMessage): Promise { + if (msg.from == "") { + throw new TypeError("Invalid from email: Expected a non-empty string."); + } + + try { + await sgMail.send(msg); + return true; + } catch (error) { + console.log(error); + return false; + } +} + +export default { + setApiKey, + send, +}; diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts index aad6760..a86b7a4 100644 --- a/packages/email/src/index.ts +++ b/packages/email/src/index.ts @@ -1,12 +1,12 @@ -import sgMail from "@sendgrid/mail"; - import { env } from "@good-dog/env"; +import emailService from "./email-service"; + export async function sendEmailVerification( toEmail: string, code: string, ): Promise { - sgMail.setApiKey(env.SENDGRID_API_KEY ?? ""); + emailService.setApiKey(env.SENDGRID_API_KEY ?? ""); const msg = { to: toEmail, @@ -15,12 +15,5 @@ export async function sendEmailVerification( html: `

Your Verification Code: ${code}

`, }; - try { - await sgMail.send(msg); - } catch (error) { - console.error(error); - return false; - } - - return true; + return await emailService.send(msg); } diff --git a/packages/trpc/package.json b/packages/trpc/package.json index c5571f3..b019d97 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -27,6 +27,7 @@ "dependencies": { "@good-dog/auth": "workspace:*", "@good-dog/db": "workspace:*", + "@good-dog/email": "workspace:*", "@good-dog/env": "workspace:*", "@tanstack/react-query": "5.56.2", "@trpc/client": "11.0.0-rc.544", diff --git a/tests/api/auth.test.ts b/tests/api/auth.test.ts index eea4e4d..7fbf63e 100644 --- a/tests/api/auth.test.ts +++ b/tests/api/auth.test.ts @@ -4,12 +4,12 @@ import { hashPassword } from "@good-dog/auth/password"; import { prisma } from "@good-dog/db"; import { $trpcCaller } from "@good-dog/trpc/server"; +import { MockEmailService } from "../mocks/MockEmailService"; import { MockNextCookies } from "../mocks/MockNextCookies"; -import { MockSendGridEmails } from "../mocks/MockSendGridEmails"; describe("auth", () => { const mockCookies = new MockNextCookies(); - const mockEmails = new MockSendGridEmails(); + const mockEmails = new MockEmailService(); const createEmailVerificationCode = async (emailConfirmed: boolean) => prisma.emailVerificationCode.upsert({ @@ -126,6 +126,23 @@ describe("auth", () => { await cleanupEmailVerificationCode(); }); + test("Email sending error", async () => { + await cleanupEmailVerificationCode(); + mockEmails.setSendError(true); + + const sendEmailVerification = async () => + $trpcCaller.sendEmailVerification({ + email: "damian@gmail.com", + }); + + expect(sendEmailVerification).toThrow( + "Email confirmation to damian@gmail.com failed to send.", + ); + + await cleanupEmailVerificationCode(); + mockEmails.setSendError(false); + }); + test("Email is already in database (not verified)", async () => { await createEmailVerificationCode(false); diff --git a/tests/mocks/MockSendGridEmails.ts b/tests/mocks/MockEmailService.ts similarity index 53% rename from tests/mocks/MockSendGridEmails.ts rename to tests/mocks/MockEmailService.ts index 0f7f03d..b3c64e5 100644 --- a/tests/mocks/MockSendGridEmails.ts +++ b/tests/mocks/MockEmailService.ts @@ -1,17 +1,30 @@ import { mock } from "bun:test"; // A mock for the cookies function in the NextJS next/header module. -export class MockSendGridEmails { +export class MockEmailService { + private haveSendError: boolean; + + constructor() { + this.haveSendError = false; + } + + setSendError(error: boolean) { + this.haveSendError = error; + } + // Applies this mock to be the cookies used by the next/header module. This method // must be called in order for this mock to be applied. async apply(): Promise { await mock.module("@sendgrid/mail", () => ({ default: { setApiKey: () => { - console.log("setting"); + console.log("Mock setting api key."); }, send: () => { - console.log("sending"); + if (this.haveSendError) { + throw new Error("Mock Sending Error"); + } + console.log("Mock email send."); }, }, })); From 9e06816748d6a7ddbe3608158b08965fd0d38b5b Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Tue, 5 Nov 2024 14:55:12 -0500 Subject: [PATCH 15/17] Mocked email service correctly --- packages/email/package.json | 3 ++- packages/email/src/email-service.ts | 2 +- packages/email/src/{index.ts => verification-email.ts} | 3 +-- packages/trpc/package.json | 2 +- packages/trpc/src/procedures/auth.ts | 3 ++- tests/api/auth.test.ts | 4 ++-- tests/mocks/MockEmailService.ts | 6 ++---- 7 files changed, 11 insertions(+), 12 deletions(-) rename packages/email/src/{index.ts => verification-email.ts} (88%) diff --git a/packages/email/package.json b/packages/email/package.json index 7ef5ebc..2fc7d1a 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -4,7 +4,8 @@ "version": "0.1.0", "type": "module", "exports": { - ".": "./src/index.ts" + "./verification-email": "./src/verification-email.ts", + "./email-service": "./src/email-service.ts" }, "license": "MIT", "scripts": { diff --git a/packages/email/src/email-service.ts b/packages/email/src/email-service.ts index 0902b43..7bea31d 100644 --- a/packages/email/src/email-service.ts +++ b/packages/email/src/email-service.ts @@ -28,7 +28,7 @@ async function send(msg: EmailMessage): Promise { await sgMail.send(msg); return true; } catch (error) { - console.log(error); + void error; return false; } } diff --git a/packages/email/src/index.ts b/packages/email/src/verification-email.ts similarity index 88% rename from packages/email/src/index.ts rename to packages/email/src/verification-email.ts index a86b7a4..60c60b8 100644 --- a/packages/email/src/index.ts +++ b/packages/email/src/verification-email.ts @@ -1,7 +1,6 @@ +import emailService from "@good-dog/email/email-service"; import { env } from "@good-dog/env"; -import emailService from "./email-service"; - export async function sendEmailVerification( toEmail: string, code: string, diff --git a/packages/trpc/package.json b/packages/trpc/package.json index b019d97..ef53faf 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -27,7 +27,7 @@ "dependencies": { "@good-dog/auth": "workspace:*", "@good-dog/db": "workspace:*", - "@good-dog/email": "workspace:*", + "@good-dog/email/index": "workspace:*", "@good-dog/env": "workspace:*", "@tanstack/react-query": "5.56.2", "@trpc/client": "11.0.0-rc.544", diff --git a/packages/trpc/src/procedures/auth.ts b/packages/trpc/src/procedures/auth.ts index f1af3c9..45620f9 100644 --- a/packages/trpc/src/procedures/auth.ts +++ b/packages/trpc/src/procedures/auth.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { deleteSessionCookie, setSessionCookie } from "@good-dog/auth/cookies"; import { comparePassword, hashPassword } from "@good-dog/auth/password"; -import { sendEmailVerification } from "@good-dog/email"; +import { sendEmailVerification } from "@good-dog/email/verification-email"; import { authenticatedProcedureBuilder, @@ -52,6 +52,7 @@ export const sendEmailVerificationProcedure = notAuthenticatedProcedureBuilder emailCode += Math.floor(Math.random() * 10); } // Send email. If sending fails, throw error. + // eslint-disable-next-line @typescript-eslint/no-unsafe-call if (!(await sendEmailVerification(input.email, emailCode))) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", diff --git a/tests/api/auth.test.ts b/tests/api/auth.test.ts index 7fbf63e..52bb9ca 100644 --- a/tests/api/auth.test.ts +++ b/tests/api/auth.test.ts @@ -75,7 +75,7 @@ describe("auth", () => { }, }); } catch (error) { - console.log(error); + void error; } }; @@ -87,7 +87,7 @@ describe("auth", () => { }, }); } catch (error) { - console.log(error); + void error; } }; diff --git a/tests/mocks/MockEmailService.ts b/tests/mocks/MockEmailService.ts index b3c64e5..ddfa089 100644 --- a/tests/mocks/MockEmailService.ts +++ b/tests/mocks/MockEmailService.ts @@ -15,16 +15,14 @@ export class MockEmailService { // Applies this mock to be the cookies used by the next/header module. This method // must be called in order for this mock to be applied. async apply(): Promise { - await mock.module("@sendgrid/mail", () => ({ + await mock.module("@good-dog/email/email-service", () => ({ default: { setApiKey: () => { console.log("Mock setting api key."); }, send: () => { - if (this.haveSendError) { - throw new Error("Mock Sending Error"); - } console.log("Mock email send."); + return !this.haveSendError; }, }, })); From 44860f0bbb510cec7b1c7b5f42d198a1461ea668 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Tue, 5 Nov 2024 14:57:35 -0500 Subject: [PATCH 16/17] bun install --- packages/trpc/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trpc/package.json b/packages/trpc/package.json index ef53faf..b019d97 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -27,7 +27,7 @@ "dependencies": { "@good-dog/auth": "workspace:*", "@good-dog/db": "workspace:*", - "@good-dog/email/index": "workspace:*", + "@good-dog/email": "workspace:*", "@good-dog/env": "workspace:*", "@tanstack/react-query": "5.56.2", "@trpc/client": "11.0.0-rc.544", From c08bd05e6b9d9413df71f58381e05f93b94723d8 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Tue, 5 Nov 2024 15:00:57 -0500 Subject: [PATCH 17/17] Added from email --- .env.example | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 266ee0a..75c63d9 100644 --- a/.env.example +++ b/.env.example @@ -19,4 +19,5 @@ VERIFICATION_FROM_EMAIL="example@gmail.com" # DATABASE_PRISMA_URL="postgresql://user:password@localhost:5432/good-dog" -# SENDGRID_API_KEY="" \ No newline at end of file +# SENDGRID_API_KEY="" +# VERIFICATION_FROM_EMAIL="example@gmail.com" \ No newline at end of file