From 2bee2959e377a0d205fa64a6df1ed200270e4ec7 Mon Sep 17 00:00:00 2001 From: Dhruvil Mehta <68022411+dhruvilmehta@users.noreply.github.com> Date: Wed, 10 Apr 2024 23:44:27 -0700 Subject: [PATCH] added course payment and auth system --- package.json | 1 + .../20240402232925_purchase/migration.sql | 54 +++++ prisma/schema.prisma | 30 +++ 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 | 98 ++++----- src/components/Signup.tsx | 194 ++++++++++++++++++ src/components/razorpay/Razorpay.tsx | 102 +++++++++ src/lib/auth.ts | 12 +- 14 files changed, 837 insertions(+), 51 deletions(-) create mode 100644 prisma/migrations/20240402232925_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 f6f4ce667..bbf1b17e4 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "next-themes": "^0.2.1", "node-fetch": "^3.3.2", "notion-client": "^6.16.0", + "razorpay": "^2.9.2", "react": "^18", "react-dom": "^18", "react-hook-form": "^7.50.1", diff --git a/prisma/migrations/20240402232925_purchase/migration.sql b/prisma/migrations/20240402232925_purchase/migration.sql new file mode 100644 index 000000000..77efa2af6 --- /dev/null +++ b/prisma/migrations/20240402232925_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 a4bc79d74..2a507b801 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 @@ -20,6 +21,8 @@ model Course { content CourseContent[] purchasedBy UserPurchases[] bookmarks Bookmark[] + purchases Purchase[] + receipts Receipt[] } model UserPurchases { @@ -132,6 +135,33 @@ model User { password String? appxUserId String? appxUsername String? + 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 { 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
Course does not exist
; + + const ifPurchasedByUser = + (await db.userPurchases.count({ + where: { + userId: session.user.id, + courseId: course.id, + }, + })) > 0 + ? true + : false; + + return ( + + + + + + {course.title} + {course.description} + + + + {ifPurchasedByUser ? ( + + ) : ( + + )} + + + ); +} diff --git a/src/app/new-courses/page.tsx b/src/app/new-courses/page.tsx new file mode 100644 index 000000000..297cfb690 --- /dev/null +++ b/src/app/new-courses/page.tsx @@ -0,0 +1,60 @@ +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import db from '@/db'; +import { authOptions } from '@/lib/auth'; +import { getServerSession } from 'next-auth'; +import Image from 'next/image'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +export default async function PurchasePage() { + const session = await getServerSession(authOptions); + if (!session?.user) { + redirect('/signin'); + } + + const courses = await db.course.findMany({ + where: { + purchasedBy: { + none: { + userId: session.user.id, + }, + }, + }, + }); + + return ( + <> +
+ {courses.length === 0 ? ( +
No New Courses to Buy
+ ) : ( + courses.map((course) => ( + + + + + + {course.title} + {course.description} + + + + + + + + + )) + )} +
+ + ); +} diff --git a/src/app/receipts/page.tsx b/src/app/receipts/page.tsx new file mode 100644 index 000000000..1c2133f92 --- /dev/null +++ b/src/app/receipts/page.tsx @@ -0,0 +1,57 @@ +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import db from '@/db'; +import { authOptions } from '@/lib/auth'; +import { getServerSession } from 'next-auth'; +import Image from 'next/image'; +import { redirect } from 'next/navigation'; + +export default async function Receipts() { + const session = await getServerSession(authOptions); + if (!session?.user) { + redirect('/signin'); + } + + const courses = await db.purchase.findMany({ + where: { + purchasedById: session.user.id, + }, + select: { + receiptId: true, + purchasedCourse: true, + }, + }); + + return ( + <> +
+ {courses.length === 0 ? ( +
No Receipts found
+ ) : ( + courses.map((course) => ( + + + + + + {course.purchasedCourse.title} + + {course.purchasedCourse.description} + + + + Receipt Id: {course.receiptId} + + + )) + )} +
+ + ); +} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx new file mode 100644 index 000000000..e39d09db6 --- /dev/null +++ b/src/app/signup/page.tsx @@ -0,0 +1,15 @@ +import { authOptions } from '@/lib/auth'; +import { getServerSession } from 'next-auth'; +import { redirect } from 'next/navigation'; +import React from 'react'; +import Signup from '@/components/Signup'; + +const SignupPage = async () => { + const session = await getServerSession(authOptions); + if (session?.user) { + redirect('/'); + } + return ; +}; + +export default SignupPage; diff --git a/src/components/Appbar.tsx b/src/components/Appbar.tsx index 723121f94..ed7fd00ad 100644 --- a/src/components/Appbar.tsx +++ b/src/components/Appbar.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { JoinDiscord } from './JoinDiscord'; import { AppbarAuth } from './AppbarAuth'; import { useSession } from 'next-auth/react'; @@ -17,6 +18,7 @@ import SearchBar from './search/SearchBar'; import MobileScreenSearch from './search/MobileScreenSearch'; export const Appbar = () => { + const router = useRouter(); const session = useSession(); const [sidebarOpen, setSidebarOpen] = useRecoilState(sidebarOpenAtom); const currentPath = usePathname(); @@ -45,57 +47,60 @@ export const Appbar = () => {
-
- {/* Search Bar for smaller devices */} - +
+ {/* Search Bar for smaller devices */} +
- {currentPath.includes('courses') && bookmarkPageUrl && ( - - )} - +
+
+
+ {currentPath.includes('courses') && bookmarkPageUrl && ( + + )} - + + + + + +
- -
-
@@ -107,14 +112,15 @@ export const Appbar = () => {
- {' '} - +
diff --git a/src/components/Signup.tsx b/src/components/Signup.tsx new file mode 100644 index 000000000..b81f75575 --- /dev/null +++ b/src/components/Signup.tsx @@ -0,0 +1,194 @@ +'use client'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Toaster } from '@/components/ui/sonner'; +import { signIn } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import React, { useRef, useState } from 'react'; + +import { toast } from 'sonner'; +const Signup = () => { + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const [requiredError, setRequiredError] = useState({ + emailReq: false, + passReq: false, + }); + + function togglePasswordVisibility() { + setIsPasswordVisible((prevState: any) => !prevState); + } + const router = useRouter(); + const email = useRef(''); + const password = useRef(''); + const name = useRef(''); + + const handleSubmit = async (e?: React.FormEvent) => { + if (e) { + e.preventDefault(); + } + + if (!email.current || !password.current) { + setRequiredError({ + emailReq: email.current ? false : true, + passReq: password.current ? false : true, + }); + return; + } + + const register = await fetch('/api/auth/register', { + method: 'POST', + body: JSON.stringify({ + name: name.current, + email: email.current, + password: password.current, + }), + }); + const data = await register.json(); + + if (register.status !== 201) { + toast(data.error, { + action: { + label: 'Close', + onClick: () => console.log('Closed Toast'), + }, + }); + return; + } + + const res = await signIn('credentials', { + username: email.current, + password: password.current, + redirect: false, + }); + + if (!res?.error) { + router.push('/'); + } else { + toast('Error Signing in', { + action: { + label: 'Close', + onClick: () => console.log('Closed Toast'), + }, + }); + } + }; + return ( +
+ + + Signup + + +
+
+ + { + name.current = e.target.value; + }} + /> +
+
+ + { + setRequiredError((prevState) => ({ + ...prevState, + emailReq: false, + })); + email.current = e.target.value; + }} + /> + {requiredError.emailReq && ( + Email is required + )} +
+
+ +
+ { + setRequiredError((prevState) => ({ + ...prevState, + passReq: false, + })); + password.current = e.target.value; + }} + onKeyDown={async (e) => { + if (e.key === 'Enter') { + setIsPasswordVisible(false); + handleSubmit(); + } + }} + /> + +
+ {requiredError.passReq && ( + Password is required + )} +
+
+ +
+
+ +
+ ); +}; + +export default Signup; diff --git a/src/components/razorpay/Razorpay.tsx b/src/components/razorpay/Razorpay.tsx new file mode 100644 index 000000000..95a98ad34 --- /dev/null +++ b/src/components/razorpay/Razorpay.tsx @@ -0,0 +1,102 @@ +'use client'; +import { Button } from '../ui/button'; +import { toast } from 'sonner'; +import { useRouter } from 'next/navigation'; + +const initializeRazorpay = () => { + return new Promise((resolve) => { + const script = document.createElement('script'); + script.src = 'https://checkout.razorpay.com/v1/checkout.js'; + + script.onload = () => { + resolve(true); + }; + script.onerror = () => { + resolve(false); + }; + + document.body.appendChild(script); + }); +}; + +type RazorPayInputType = { + courseId: number; + userId: string; +}; + +export const RazorPayComponent = ({ courseId, userId }: RazorPayInputType) => { + const router = useRouter(); + + const makePayment = async () => { + const res = await initializeRazorpay(); + + if (!res) { + alert('Razorpay SDK Failed to load'); + return; + } + + const data = await fetch('/api/razorpay', { + method: 'POST', + body: JSON.stringify({ + courseId, + userId, + }), + }).then((res) => res.json()); + + const options = { + key_id: data.razorPayKey, + name: '100xdevs', + currency: data.currency, + amount: data.amount, + order_id: data.id, + description: 'Thank you for purchasing course', + image: '/harkirat.png', + handler: async (response: any) => { + const verify = await fetch('/api/razorpay/verify', { + method: 'POST', + body: JSON.stringify({ ...response, userId, courseId }), + }); + const data = await verify.json(); + if (verify.status !== 200) { + toast(data.error, { + action: { + label: 'Close', + onClick: () => console.log('Closed Toast'), + }, + }); + } else { + toast(data.message, { + action: { + label: 'Close', + onClick: () => console.log('Closed Toast'), + }, + }); + router.push('/'); + } + }, + }; + + const paymentObject = new (window as any).Razorpay(options); + paymentObject.on('payment.failed', () => { + toast('Payment did not execute', { + action: { + label: 'Close', + onClick: () => console.log('Closed Toast'), + }, + }); + }); + paymentObject.open(); + }; + + return ( +
+ +
+ ); +}; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 12d152129..050f837ee 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -113,11 +113,13 @@ export const authOptions = { name: true, }, }); - if ( - userDb && - userDb.password && - (await bcrypt.compare(credentials.password, userDb.password)) - ) { + if (userDb && userDb.password) { + const valid = await bcrypt.compare( + credentials.password, + userDb.password, + ); + if (!valid) return null; + const jwt = await generateJWT({ id: userDb.id, });