diff --git a/.env.example b/.env.example index 1e526d56..961c3c67 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,11 @@ -# # Database -# DATABASE_URL="postgres://postgres:password@localhost:5432/postgres" - -# # AUTH -# NEXTAUTH_SECRET="koXrQGB5TFD4KALDX4kAvnQ5RHHvAOIzB" NEXTAUTH_URL="http://localhost:3000" +RAZORPAY_WEBHOOK_SECRET= +RAZORPAY_ID= +RAZORPAY_SECRET= # PRISMA STUDIO DOCKER POSTGRES_URL=postgres://postgres:postgres@db:5432/job-board-db @@ -19,14 +17,12 @@ AWS_S3_REGION=your-aws-region AWS_S3_ACCESS_KEY_ID=your-access-ID AWS_S3_SECRET_ACCESS_KEY=your-access-key AWS_S3_BUCKET_NAME=your-bucket -# + # Bunny CDN -# CDN_API_KEY=api-key CDN_BASE_UPLOAD_URL=https://sg.storage.bunnycdn.com/job-board/assets CDN_BASE_ACCESS_URL=https://job-board.b-cdn.net/assets - NEXT_PUBLIC_GOOGLE_MAPS_API_KEY= diff --git a/README.md b/README.md index d41afffc..e00bf845 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Project Name: Job Board + [All about job board](https://marmalade-height-05f.notion.site/100xDevs-Job-board-ab8ca399180d49e4bc0c2ff5c81dfb08?pvs=25)
[Project Status](https://marmalade-height-05f.notion.site/Job-board-10315651c69c80b581b5f7b64667341c) + ## Table of Contents - [Description](#description) @@ -38,12 +40,20 @@ Follow these steps to set up the repository locally and run it. ```bash # - # Database + <<<<<<< HEAD + # Database + ======= + # Database + >>>>>>> main # DATABASE_URL="postgres://postgres:password@localhost:5432/postgres" # - # AUTH + <<<<<<< HEAD + # AUTH + ======= + # AUTH + >>>>>>> main # NEXTAUTH_SECRET="koXrQGB5TFD4KALDX4kAvnQ5RHHvAOIzB" NEXTAUTH_URL="http://localhost:3000" @@ -51,12 +61,20 @@ Follow these steps to set up the repository locally and run it. # # Bunny CDN # + <<<<<<< HEAD + CDN_API_KEY=api-key + CDN_BASE_UPLOAD_URL=https://sg.storage.bunnycdn.com/job-board/assets + CDN_BASE_ACCESS_URL=https://job-board.b-cdn.net/assets + ======= CDN_SZ_NAME= CDN_BASE_PATH= CDN_API_KEY= + >>>>>>> main ``` -2. To generate AUTH_SECRET, +2. Change the hostname in `next.config.js` with your CDN access hostname by Ref of provided example. + +3. To generate AUTH_SECRET, Run this command in your terminal: @@ -68,7 +86,6 @@ Follow these steps to set up the repository locally and run it. [Run in browser](https://www.cryptool.org/en/cto/openssl/) - ### Running the Project with Docker ```bash @@ -104,43 +121,44 @@ Emails: 'user@gmail.com, admin@gmail.com'; Password: '123456'; ``` - ## Steps to create a BunnyCDN storage for this repo: 1. **Create a storage zone:** - Create a storage zone + Create a storage zone 2. **Connect the storage zone to a pull zone:** - Connect the storage zone to a pull zone + Connect the storage zone to a pull zone -4. **Set environment variables:** +3. **Set environment variables:** Go to the FTP & API Access section in the storage zone and add the following environment variables: ```bash CDN_API_KEY= ``` - + Which you can find in the storage -> [storage name] -> FTP & API Access section - + CDN_API_KEY --- - ```bash - CDN_BASE_UPLOAD_URL= - ``` - Which is https://[your-hostname]/[storage-name]/[any folder name you might have added otherwise empty] - +```bash +CDN_BASE_UPLOAD_URL= +``` + +Which is https://[your-hostname]/[storage-name]/[any folder name you might have added otherwise empty] + CDN_BASE_UPLOAD_URL --- - - ```bash - CDN_BASE_ACCESS_URL= - ``` - Which is https://[your-pull-zone-hostname]/[any folder name you might have added otherwise empty] or get link from the dashboard as mentioned below - + +```bash +CDN_BASE_ACCESS_URL= +``` + +Which is https://[your-pull-zone-hostname]/[any folder name you might have added otherwise empty] or get link from the dashboard as mentioned below +  CDN_BASE_ACCESS_URL diff --git a/package.json b/package.json index b2fbfc69..08940f6b 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "next-auth": "^4.24.7", "next-themes": "^0.3.0", "nextjs-toploader": "^1.6.12", + "razorpay": "^2.9.4", "react": "^18", "react-dom": "^18", "react-hook-form": "^7.52.2", diff --git a/prisma/migrations/20240916193844_add_razorpay_schema/migration.sql b/prisma/migrations/20240916193844_add_razorpay_schema/migration.sql new file mode 100644 index 00000000..65a00077 --- /dev/null +++ b/prisma/migrations/20240916193844_add_razorpay_schema/migration.sql @@ -0,0 +1,31 @@ +-- AlterTable +ALTER TABLE "Job" ADD COLUMN "transactionId" TEXT; + +-- CreateTable +CREATE TABLE "RazorpayTransactions" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "razorpayPaymentId" TEXT NOT NULL, + "razorpayOrderId" TEXT NOT NULL, + "razorpaySignature" TEXT NOT NULL, + "status" TEXT NOT NULL, + "jobId" TEXT, + + CONSTRAINT "RazorpayTransactions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "RazorpayTransactions_razorpayPaymentId_key" ON "RazorpayTransactions"("razorpayPaymentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RazorpayTransactions_razorpayOrderId_key" ON "RazorpayTransactions"("razorpayOrderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RazorpayTransactions_razorpaySignature_key" ON "RazorpayTransactions"("razorpaySignature"); + +-- CreateIndex +CREATE UNIQUE INDEX "RazorpayTransactions_jobId_key" ON "RazorpayTransactions"("jobId"); + +-- AddForeignKey +ALTER TABLE "RazorpayTransactions" ADD CONSTRAINT "RazorpayTransactions_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e3eba55e..ab7176ad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,28 +19,42 @@ model User { } model Job { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String title String description String? - companyName String @map("company_name") - companyBio String @map("company_bio") - companyEmail String @map("company_email") - category String + companyName String @map("company_name") + companyBio String @map("company_bio") + companyEmail String @map("company_email") + category String type String workMode WorkMode @map("work_mode") currency Currency @default(INR) city String address String application String - companyLogo String - hasSalaryRange Boolean @default(false) @map("has_salary_range") + companyLogo String + hasSalaryRange Boolean @default(false) @map("has_salary_range") minSalary Int? maxSalary Int? - isVerifiedJob Boolean @default(false) @map("is_verified_job") - postedAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) + isVerifiedJob Boolean @default(false) @map("is_verified_job") + postedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id]) + transaction RazorpayTransactions? + transactionId String? +} + +model RazorpayTransactions { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + razorpayPaymentId String @unique + razorpayOrderId String @unique + razorpaySignature String @unique + status String + job Job? @relation(fields: [jobId], references: [id]) + jobId String? @unique } enum Currency { diff --git a/src/actions/job.action.ts b/src/actions/job.action.ts index 3b752977..b8778dc1 100644 --- a/src/actions/job.action.ts +++ b/src/actions/job.action.ts @@ -16,7 +16,9 @@ import { getAllJobsAdditonalType, getJobType } from '@/types/jobs.types'; type additional = { isVerifiedJob: boolean; + jobId: string; }; + export const createJob = withServerActionAsyncCatcher< JobPostSchemaType, ServerActionReturnType @@ -39,7 +41,8 @@ export const createJob = withServerActionAsyncCatcher< maxSalary, minSalary, } = result; - await prisma.job.create({ + + const createdJob = await prisma.job.create({ data: { userId: '1', // Default to 1 since there's no session to check for user id title, @@ -61,8 +64,8 @@ export const createJob = withServerActionAsyncCatcher< }, }); const message = 'Job created successfully, waiting for admin approval'; - const additonal = { isVerifiedJob: false }; - return new SuccessResponse(message, 201, additonal).serialize(); + const additional = { isVerifiedJob: false, jobId: createdJob.id }; + return new SuccessResponse(message, 201, additional).serialize(); }); export const getAllJobs = withServerActionAsyncCatcher< @@ -147,6 +150,26 @@ export const getJobById = withServerActionAsyncCatcher< }).serialize(); }); +export const createTransactions = async (response: any) => { + try { + await prisma.razorpayTransactions.create({ + data: { + razorpayOrderId: response.razorpayOrderId, + razorpayPaymentId: response.razorpayPaymentId, + razorpaySignature: response.razorpaySignature, + status: response.status, + job: { connect: { id: response.jobId } }, + }, + }); + return new SuccessResponse( + 'Transaction successfully, waiting for admin approval', + 201 + ).serialize(); + } catch (error) { + console.error('Verification error createTransactions:', error); + } +}; + export const getCityFilters = async () => { const response = await prisma.job.findMany({ select: { diff --git a/src/app/api/order/route.ts b/src/app/api/order/route.ts new file mode 100644 index 00000000..7d5262bc --- /dev/null +++ b/src/app/api/order/route.ts @@ -0,0 +1,39 @@ +import Razorpay from 'razorpay'; +import { NextRequest, NextResponse } from 'next/server'; + +import { z } from 'zod'; + +const razorpayInstance = new Razorpay({ + key_id: process.env.RAZORPAY_ID || '', + key_secret: process.env.RAZORPAY_SECRET || '', +}); + +const payloadSchema = z.object({ + amount: z.string(), + currency: z.string(), +}); + +export async function POST(request: NextRequest) { + try { + const payload = await request.json(); + + const { amount, currency } = payloadSchema.parse(payload); + const options = { + amount, + currency, + receipt: 'rcp1', + }; + const order = await razorpayInstance.orders.create(options); + + return NextResponse.json( + { + id: order.id, + currency: order.currency, + amount: order.amount, + }, + { status: 200 } + ); + } catch (error) { + return NextResponse.json({ error }, { status: 500 }); + } +} diff --git a/src/app/api/verify/route.ts b/src/app/api/verify/route.ts new file mode 100644 index 00000000..2a8ae929 --- /dev/null +++ b/src/app/api/verify/route.ts @@ -0,0 +1,52 @@ +import { createJob, createTransactions } from '@/actions/job.action'; +import { NextRequest, NextResponse } from 'next/server'; +import { validateWebhookSignature } from 'razorpay/dist/utils/razorpay-utils'; + +export async function POST(req: NextRequest) { + const jsonBody = await req.json(); + const razorpay_signature: string | null = req.headers.get( + 'X-Razorpay-Signature' + ); + + if (!razorpay_signature) + return NextResponse.json({ error: 'Signature not found' }, { status: 404 }); + + const isPaymentValid: boolean = validateWebhookSignature( + JSON.stringify(jsonBody), + razorpay_signature, + process.env.RAZORPAY_WEBHOOK_SECRET! + ); + + if (!isPaymentValid) { + return NextResponse.json( + { error: 'Payment not verified. Payment signature invalid' }, + { status: 404 } + ); + } + + const jobData = jsonBody.payload.payment.entity.notes; + + try { + const response = await createJob(jobData); + + if (!response.status) { + return NextResponse.json({ error: response.message }, { status: 404 }); + } + + const transactionData = { + razorpayOrderId: jsonBody.payload.payment.entity.order_id, + razorpayPaymentId: jsonBody.payload.payment.entity.id, + razorpaySignature: razorpay_signature, + jobId: response?.additional?.jobId, + status: jsonBody.payload.payment.entity.status, + }; + await createTransactions(transactionData); + + return NextResponse.json( + { message: 'Purchase Successful' }, + { status: 200 } + ); + } catch (error) { + return NextResponse.json({ error }, { status: 409 }); + } +} diff --git a/src/components/job-form.tsx b/src/components/job-form.tsx index ac217430..b501b439 100644 --- a/src/components/job-form.tsx +++ b/src/components/job-form.tsx @@ -1,5 +1,5 @@ 'use client'; -import { createJob } from '@/actions/job.action'; + import { Form, FormControl, @@ -24,7 +24,8 @@ import { } from '../lib/validators/jobs.validator'; import { Button } from './ui/button'; import { Input } from './ui/input'; -import { useToast } from './ui/use-toast'; +import { useRazorpay } from '@/hooks/useRazorPay'; +import { useRouter } from 'next/navigation'; import { Calendar, LucideRocket, MailOpenIcon } from 'lucide-react'; import DescriptionEditor from './DescriptionEditor'; import Image from 'next/image'; @@ -34,7 +35,9 @@ import { Label } from './ui/label'; import { GmapsAutocompleteAddress } from './gmaps-autosuggest'; const PostJobForm = () => { - const { toast } = useToast(); + const router = useRouter(); + const processPayment = useRazorpay(); + const companyLogoImg = useRef(null); const form = useForm({ resolver: zodResolver(JobPostSchema), @@ -111,31 +114,16 @@ const PostJobForm = () => { }; const handleFormSubmit = async (data: JobPostSchemaType) => { - try { - data.companyLogo = - (await submitImage(file)) ?? 'https://wwww.example.com'; - ``; - const response = await createJob(data); - if (!response.status) { - return toast({ - title: response.name || 'Something went wrong', - description: response.message || 'Internal server error', - variant: 'destructive', - }); - } - toast({ - title: response.message, - variant: 'success', - }); - setPreviewImg(null); - form.reset(form.formState.defaultValues); - } catch (_error) { - toast({ - title: 'Something went wrong will creating job', - description: 'Internal server error', - variant: 'destructive', - }); - } + data.companyLogo = (await submitImage(file)) ?? ''; + await processPayment({ + amount: 1000, + data, + successCallback: () => { + router.push('/'); //Note: Check where to redirect after payment + }, + }); + setPreviewImg(null); + form.reset(form.formState.defaultValues); }; const watchHasSalaryRange = form.watch('hasSalaryRange'); @@ -470,18 +458,21 @@ const PostJobForm = () => { -
- -

Payment

-
diff --git a/src/hooks/useRazorPay.ts b/src/hooks/useRazorPay.ts new file mode 100644 index 00000000..26c7a808 --- /dev/null +++ b/src/hooks/useRazorPay.ts @@ -0,0 +1,95 @@ +import { toast } from '@/components/ui/use-toast'; +import { JobPostSchemaType } from '@/lib/validators/jobs.validator'; + +const intializeRazorpay = () => { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://checkout.razorpay.com/v1/checkout.js'; + script.async = true; + script.onload = () => { + resolve(true); + }; + script.onerror = () => { + reject(false); + }; + document.body.appendChild(script); + }); +}; + +export const useRazorpay = () => { + const processPayment = async ({ + data, + amount, + successCallback, + }: { + amount: number; + data: JobPostSchemaType; + successCallback: () => void; + }) => { + const razorpay = await intializeRazorpay(); + if (!razorpay) { + toast({ + title: 'Failed to initialize Razorpay', + variant: 'destructive', + }); + return; + } + + const order = await fetch('/api/order', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + amount: (amount * 100).toString(), + currency: 'INR', + }), + }); + const orderDetails = await order.json(); + + if (!orderDetails.id) { + toast({ + title: 'Failed to create order', + variant: 'destructive', + }); + return; + } + const options = { + key: process.env.RAZORPAY_ID, + amount: orderDetails.amount, + currency: orderDetails.currency, + name: data.title, + description: data.description, + order_id: orderDetails.id, + prefill: { + email: data.companyEmail, + }, + notes: data, + modal: { + ondismiss: () => { + toast({ + title: 'Payment Cancelled', + variant: 'destructive', + }); + }, + }, + handler: () => { + toast({ + title: 'Payment Successful Thank you!', + variant: 'success', + }); + successCallback && successCallback(); + }, + }; + + const paymentObject = new (window as any).Razorpay(options); + paymentObject.on('payment.failed', (response: any) => { + toast({ + title: response.error.description, + variant: 'destructive', + }); + }); + paymentObject.open(); + }; + return processPayment; +}; diff --git a/src/lib/constant/app.constant.ts b/src/lib/constant/app.constant.ts index c97c7d93..29276121 100644 --- a/src/lib/constant/app.constant.ts +++ b/src/lib/constant/app.constant.ts @@ -15,7 +15,6 @@ export const navbar = [ { id: 4, label: 'Testimonials', path: '/' }, { id: 5, label: 'FAQs', path: '/' }, { id: 6, label: 'Post a Job', path: APP_PATHS.POST_JOB }, - ]; export const socials: {