From 59c18e5ed6344665ce1b732250b94ab9fde8b08d Mon Sep 17 00:00:00 2001 From: Dhruvil Mehta <68022411+dhruvilmehta@users.noreply.github.com> Date: Mon, 20 May 2024 18:41:55 -0700 Subject: [PATCH] Added user registration and Course purchase system --- package.json | 1 + .../migration.sql | 54 +++++ prisma/schema.prisma | 132 +++++++----- src/app/api/auth/register/route.ts | 36 ++++ src/app/api/razorpay/route.ts | 83 ++++++++ src/app/api/razorpay/verify/route.ts | 80 ++++++++ src/app/new-courses/[courseId]/page.tsx | 66 ++++++ src/app/new-courses/page.tsx | 60 ++++++ src/app/receipts/page.tsx | 57 +++++ src/app/signup/page.tsx | 15 ++ src/components/Appbar.tsx | 11 + src/components/Signup.tsx | 194 ++++++++++++++++++ src/components/VideoPlayer2.tsx | 2 +- .../profile-menu/ProfileDropdown.tsx | 13 +- src/components/razorpay/Razorpay.tsx | 102 +++++++++ src/lib/auth.ts | 12 +- tsconfig.json | 10 +- 17 files changed, 866 insertions(+), 62 deletions(-) create mode 100644 prisma/migrations/20240521013022_add_user_course_purchase/migration.sql create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/api/razorpay/route.ts create mode 100644 src/app/api/razorpay/verify/route.ts create mode 100644 src/app/new-courses/[courseId]/page.tsx create mode 100644 src/app/new-courses/page.tsx create mode 100644 src/app/receipts/page.tsx create mode 100644 src/app/signup/page.tsx create mode 100644 src/components/Signup.tsx create mode 100644 src/components/razorpay/Razorpay.tsx diff --git a/package.json b/package.json index d6542fb01..60bbe4312 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "nextjs-toploader": "^1.6.11", "node-fetch": "^3.3.2", "notion-client": "^6.16.0", + "razorpay": "^2.9.2", "pdf-lib": "^1.17.1", "react": "^18", "react-dom": "^18", diff --git a/prisma/migrations/20240521013022_add_user_course_purchase/migration.sql b/prisma/migrations/20240521013022_add_user_course_purchase/migration.sql new file mode 100644 index 000000000..629412c89 --- /dev/null +++ b/prisma/migrations/20240521013022_add_user_course_purchase/migration.sql @@ -0,0 +1,54 @@ +-- AlterTable +ALTER TABLE "Course" ADD COLUMN "price" INTEGER NOT NULL DEFAULT 1000; + +-- CreateTable +CREATE TABLE "Receipt" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "courseId" INTEGER NOT NULL DEFAULT 0, + "razorpayOrderId" TEXT, + + CONSTRAINT "Receipt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Purchase" ( + "id" TEXT NOT NULL, + "receiptId" TEXT NOT NULL, + "razorpay_payment_id" TEXT NOT NULL, + "razorpay_order_id" TEXT NOT NULL, + "razorpay_signature" TEXT NOT NULL, + "purchasedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "paymentVerified" BOOLEAN NOT NULL DEFAULT false, + "purchasedCourseId" INTEGER NOT NULL, + "purchasedById" TEXT NOT NULL, + + CONSTRAINT "Purchase_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Receipt_razorpayOrderId_key" ON "Receipt"("razorpayOrderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Purchase_receiptId_key" ON "Purchase"("receiptId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Purchase_razorpay_payment_id_key" ON "Purchase"("razorpay_payment_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Purchase_razorpay_order_id_key" ON "Purchase"("razorpay_order_id"); + +-- AddForeignKey +ALTER TABLE "Receipt" ADD CONSTRAINT "Receipt_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Receipt" ADD CONSTRAINT "Receipt_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_receiptId_fkey" FOREIGN KEY ("receiptId") REFERENCES "Receipt"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_purchasedCourseId_fkey" FOREIGN KEY ("purchasedCourseId") REFERENCES "Course"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_purchasedById_fkey" FOREIGN KEY ("purchasedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 39874f139..e04408163 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,6 +10,7 @@ datasource db { model Course { id Int @id @default(autoincrement()) + price Int @default(1000) appxCourseId Int discordRoleId String title String @@ -19,8 +20,10 @@ model Course { slug String content CourseContent[] purchasedBy UserPurchases[] + purchases Purchase[] + receipts Receipt[] certificate Certificate[] - certIssued Boolean @default(false) + certIssued Boolean @default(false) } model UserPurchases { @@ -64,12 +67,12 @@ model CourseContent { } model Certificate { - id String @id @default(cuid()) - slug String @default("certId") - user User @relation(fields: [userId], references: [id]) - userId String - course Course @relation(fields: [courseId], references: [id]) - courseId Int + id String @id @default(cuid()) + slug String @default("certId") + user User @relation(fields: [userId], references: [id]) + userId String + course Course @relation(fields: [courseId], references: [id]) + courseId Int @@unique([userId, courseId]) } @@ -147,6 +150,33 @@ model User { questions Question[] answers Answer[] certificate Certificate[] + transactions Purchase[] + receipts Receipt[] +} + +model Receipt { + id String @id @default(cuid()) + user User @relation(fields: [userId], references: [id]) + userId String + course Course @relation(fields: [courseId], references: [id]) + courseId Int @default(0) + razorpayOrderId String? @unique + purchase Purchase? +} + +model Purchase { + id String @id @default(cuid()) + receipt Receipt @relation(fields: [receiptId], references: [id]) + receiptId String @unique + razorpay_payment_id String @unique + razorpay_order_id String @unique + razorpay_signature String + purchasedAt DateTime @default(now()) + paymentVerified Boolean @default(false) + purchasedCourse Course @relation(fields: [purchasedCourseId], references: [id]) + purchasedCourseId Int + purchasedBy User @relation(fields: [purchasedById], references: [id]) + purchasedById String } model DiscordConnect { @@ -206,74 +236,76 @@ model Comment { votes Vote[] isPinned Boolean @default(false) } + model Question { - id Int @id @default(autoincrement()) - title String - content String - slug String @unique - createdAt DateTime @default(now()) - author User @relation(fields: [authorId], references: [id]) - authorId String - upvotes Int @default(0) - downvotes Int @default(0) + id Int @id @default(autoincrement()) + title String + content String + slug String @unique + createdAt DateTime @default(now()) + author User @relation(fields: [authorId], references: [id]) + authorId String + upvotes Int @default(0) + downvotes Int @default(0) totalanswers Int @default(0) - answers Answer[] - votes Vote[] - tags String[] - updatedAt DateTime @updatedAt + answers Answer[] + votes Vote[] + tags String[] + updatedAt DateTime @updatedAt @@index([authorId]) } model Answer { - id Int @id @default(autoincrement()) - content String - createdAt DateTime @default(now()) - question Question @relation(fields: [questionId], references: [id]) - questionId Int - author User @relation(fields: [authorId], references: [id]) - authorId String - votes Vote[] - upvotes Int @default(0) - downvotes Int @default(0) - totalanswers Int @default(0) - parentId Int? - responses Answer[] @relation("AnswerToAnswer") - parent Answer? @relation("AnswerToAnswer", fields: [parentId], references: [id]) - updatedAt DateTime @updatedAt + id Int @id @default(autoincrement()) + content String + createdAt DateTime @default(now()) + question Question @relation(fields: [questionId], references: [id]) + questionId Int + author User @relation(fields: [authorId], references: [id]) + authorId String + votes Vote[] + upvotes Int @default(0) + downvotes Int @default(0) + totalanswers Int @default(0) + parentId Int? + responses Answer[] @relation("AnswerToAnswer") + parent Answer? @relation("AnswerToAnswer", fields: [parentId], references: [id]) + updatedAt DateTime @updatedAt @@index([questionId]) @@index([authorId]) @@index([parentId]) } - model Vote { - id Int @id @default(autoincrement()) - questionId Int? - question Question? @relation(fields: [questionId], references: [id]) - answerId Int? - answer Answer? @relation(fields: [answerId], references: [id]) - commentId Int? - comment Comment? @relation(fields: [commentId], references: [id]) - userId String - user User @relation(fields: [userId], references: [id]) - voteType VoteType - createdAt DateTime @default(now()) + id Int @id @default(autoincrement()) + questionId Int? + question Question? @relation(fields: [questionId], references: [id]) + answerId Int? + answer Answer? @relation(fields: [answerId], references: [id]) + commentId Int? + comment Comment? @relation(fields: [commentId], references: [id]) + userId String + user User @relation(fields: [userId], references: [id]) + voteType VoteType + createdAt DateTime @default(now()) - @@unique([questionId, userId]) - @@unique([answerId, userId]) - @@unique([commentId, userId]) + @@unique([questionId, userId]) + @@unique([answerId, userId]) + @@unique([commentId, userId]) } enum VoteType { UPVOTE DOWNVOTE } + enum PostType { QUESTION ANSWER } + enum CommentType { INTRO DEFAULT diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 000000000..8efbf1b82 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; +import db from '@/db'; +import bcrypt from 'bcrypt'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; + +export async function POST(req: NextRequest) { + try { + const { name, email, password } = await req.json(); + + const hashedPassword = await bcrypt.hash(password, 10); + await db.user.create({ + data: { + email, + name, + password: hashedPassword, + }, + }); + + return NextResponse.json( + { message: 'Account created sucessfully' }, + { status: 201 }, + ); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError) { + return NextResponse.json( + { error: 'Email already taken.' }, + { status: 400 }, + ); + } + console.log(e); + return NextResponse.json( + { error: 'Failed to parse JSON input' }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/razorpay/route.ts b/src/app/api/razorpay/route.ts new file mode 100644 index 000000000..e06dd515a --- /dev/null +++ b/src/app/api/razorpay/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Razorpay from 'razorpay'; +import db from '@/db'; +import { z } from 'zod'; + +const schema = z.object({ + courseId: z.number(), + userId: z.string(), +}); + +const razorpay = new Razorpay({ + key_id: process.env.RAZORPAY_KEY_ID!, + key_secret: process.env.RAZORPAY_SECRET!, +}); + +export async function POST(req: NextRequest) { + const reqBody = await req.json(); + const body = schema.safeParse(reqBody); + + if (!body.success) { + return NextResponse.json( + { + error: 'Error parsing the body of the request', + }, + { + status: 422, + }, + ); + } + + const course = await db.course.findFirst({ + where: { + id: body.data.courseId, + }, + }); + + if (!course) + return NextResponse.json({ error: 'Course Not Found' }, { status: 404 }); + + const receipt = await db.receipt.create({ + data: { + userId: body.data.userId, + courseId: body.data.courseId, + }, + }); + + const payment_capture = 1; + const amount = course.price; + const options = { + amount: (amount * 100).toString(), + currency: 'INR', + receipt: receipt.id, + payment_capture, + notes: { + userId: body.data.userId, + courseId: body.data.courseId, + receipt: receipt.id, + }, + }; + + try { + const response = await razorpay.orders.create(options); + + await db.receipt.update({ + where: { + id: receipt.id, + }, + data: { + razorpayOrderId: response.id, + }, + }); + + return NextResponse.json({ + id: response.id, + currency: response.currency, + amount: response.amount, + razorPayKey: process.env.RAZORPAY_KEY_ID, + }); + } catch (err) { + console.log(err); + return NextResponse.json(err); + } +} diff --git a/src/app/api/razorpay/verify/route.ts b/src/app/api/razorpay/verify/route.ts new file mode 100644 index 000000000..aad654f4b --- /dev/null +++ b/src/app/api/razorpay/verify/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validatePaymentVerification } from 'razorpay/dist/utils/razorpay-utils'; +import { z } from 'zod'; +import db from '@/db'; + +const razorPayZodSchema = z.object({ + userId: z.string(), + courseId: z.number(), + razorpay_order_id: z.string(), + razorpay_payment_id: z.string(), + razorpay_signature: z.string(), +}); + +export async function POST(req: NextRequest) { + const jsonBody = await req.json(); + + const body = razorPayZodSchema.safeParse(jsonBody); + + if (!body.success) { + return NextResponse.json( + { + error: 'Invalid Body', + }, + { status: 422 }, + ); + } + + const { + razorpay_order_id, + razorpay_payment_id, + razorpay_signature, + courseId, + userId, + } = body.data; + + const isPaymentValid = validatePaymentVerification( + { + order_id: razorpay_order_id, + payment_id: razorpay_payment_id, + }, + razorpay_signature, + process.env.RAZORPAY_SECRET!, + ); + + if (!isPaymentValid) + return NextResponse.json( + { error: 'Payment not verified' }, + { status: 404 }, + ); + + const receipt = await db.receipt.findFirst({ + where: { + razorpayOrderId: razorpay_order_id, + }, + }); + + if (!receipt) + return NextResponse.json({ error: 'Receipt not found' }, { status: 404 }); + + await db.purchase.create({ + data: { + receiptId: receipt.id, + razorpay_order_id, + razorpay_payment_id, + razorpay_signature, + paymentVerified: isPaymentValid, + purchasedById: userId, + purchasedCourseId: courseId, + }, + }); + + await db.userPurchases.create({ + data: { + userId, + courseId, + }, + }); + + return NextResponse.json({ message: 'Purchase Successful' }, { status: 200 }); +} diff --git a/src/app/new-courses/[courseId]/page.tsx b/src/app/new-courses/[courseId]/page.tsx new file mode 100644 index 000000000..daf0e9bc1 --- /dev/null +++ b/src/app/new-courses/[courseId]/page.tsx @@ -0,0 +1,66 @@ +import { RazorPayComponent } from '@/components/razorpay/Razorpay'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import Image from 'next/image'; +import db from '@/db'; +import { authOptions } from '@/lib/auth'; +import { getServerSession } from 'next-auth'; +import { redirect } from 'next/navigation'; + +export default async function CourseDetails({ + params, +}: { + params: { courseId: string[] }; +}) { + const session = await getServerSession(authOptions); + if (!session?.user) { + redirect('/signin'); + } + const course = await db.course.findFirst({ + where: { + id: Number(params.courseId), + }, + }); + if (!course) return