From 5b24e2917533a9d8d35c02b1e733fd014c38d1ae Mon Sep 17 00:00:00 2001 From: Paribesh Nepal <100255987+Paribesh01@users.noreply.github.com> Date: Wed, 16 Oct 2024 16:42:05 +0530 Subject: [PATCH] feat:added expiry date feature (#497) * feat:added expiry date feature * feat: some style change * fix:typo error * feat: added logic to run cron job * feat: ui change for the calender --------- Co-authored-by: Rohan --- package.json | 6 +- prisma/schema.prisma | 3 + prisma/seed.ts | 23 +++++++- src/actions/corn.ts | 20 +++++++ src/actions/job.action.ts | 33 ++++++++++- src/app/page.tsx | 2 + src/components/job-form.tsx | 84 +++++++++++++++++++++++++++- src/components/ui/calendar.tsx | 66 ++++++++++++++++++++++ src/lib/validators/jobs.validator.ts | 21 +++++++ 9 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 src/actions/corn.ts create mode 100644 src/components/ui/calendar.tsx diff --git a/package.json b/package.json index ce284728..c6562990 100644 --- a/package.json +++ b/package.json @@ -54,10 +54,12 @@ "@types/lodash": "^4.17.7", "@types/uuid": "^10.0.0", "@uidotdev/usehooks": "^2.4.1", + "100xdevs-job-board": "file:", "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "date-fns": "^2.30.0", "dayjs": "^1.11.13", "framer-motion": "^11.9.0", "jiti": "^1.21.6", @@ -71,6 +73,7 @@ "node-cron": "^3.0.3", "nodemailer": "^6.9.15", "react": "^18", + "react-day-picker": "^8.10.1", "react-dom": "^18", "react-hook-form": "^7.52.2", "react-icons": "^5.2.1", @@ -86,6 +89,7 @@ "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/node": "^20.16.10", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.16", "@types/react": "^18", "@types/react-dom": "^18", @@ -102,4 +106,4 @@ "ts-node": "^10.9.2", "typescript": "^5.6.2" } -} \ No newline at end of file +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f60674e0..7dfb82bd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -63,6 +63,9 @@ model Job { application String companyLogo String skills String[] + expired Boolean @default(false) + hasExpiryDate Boolean @default(false) @map("has_expiry_date") + expiryDate DateTime? hasSalaryRange Boolean @default(false) @map("has_salary_range") minSalary Int? maxSalary Int? diff --git a/prisma/seed.ts b/prisma/seed.ts index 7a16f7f6..795cd564 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -36,6 +36,8 @@ let jobs = [ maxExperience: 2, companyLogo: '', hasSalaryRange: true, + hasExpiryDate: true, + expiryDate: new Date(new Date().setDate(new Date().getDate() + 49)), minSalary: 60000, maxSalary: 80000, isVerifiedJob: true, @@ -53,7 +55,8 @@ let jobs = [ type: EmployementType.Full_time, workMode: WorkMode.office, currency: Currency.USD, - + hasExpiryDate: true, + expiryDate: new Date(new Date().setDate(new Date().getDate() + 49)), hasExperiencerange: false, companyLogo: '', hasSalaryRange: false, @@ -74,6 +77,7 @@ let jobs = [ type: EmployementType.Full_time, workMode: WorkMode.hybrid, currency: Currency.USD, + hasExpiryDate: false, hasExperiencerange: true, minExperience: 3, maxExperience: 4, @@ -100,6 +104,8 @@ let jobs = [ hasExperiencerange: true, minExperience: 1, maxExperience: 2, + hasExpiryDate: true, + expiryDate: new Date(new Date().setDate(new Date().getDate() + 49)), companyLogo: '', hasSalaryRange: true, minSalary: 50000, @@ -120,6 +126,8 @@ let jobs = [ type: EmployementType.Full_time, workMode: WorkMode.hybrid, currency: Currency.USD, + hasExpiryDate: true, + expiryDate: new Date(new Date().setDate(new Date().getDate() + 49)), hasExperiencerange: false, companyLogo: '', hasSalaryRange: true, @@ -145,6 +153,8 @@ let jobs = [ minExperience: 1, maxExperience: 2, companyLogo: '', + hasExpiryDate: true, + expiryDate: new Date(new Date().setDate(new Date().getDate() + 49)), hasSalaryRange: true, minSalary: 80000, maxSalary: 100000, @@ -165,6 +175,7 @@ let jobs = [ workMode: WorkMode.remote, currency: Currency.USD, hasExperiencerange: true, + hasExpiryDate: false, minExperience: 1, maxExperience: 2, companyLogo: '', @@ -187,6 +198,8 @@ let jobs = [ workMode: WorkMode.hybrid, currency: Currency.USD, hasExperiencerange: true, + hasExpiryDate: true, + expiryDate: new Date(new Date().setDate(new Date().getDate() + 49)), minExperience: 1, maxExperience: 2, companyLogo: '', @@ -208,6 +221,7 @@ let jobs = [ workMode: WorkMode.office, currency: Currency.USD, hasExperiencerange: true, + hasExpiryDate: false, minExperience: 1, maxExperience: 2, companyLogo: '', @@ -233,6 +247,8 @@ let jobs = [ minExperience: 1, maxExperience: 2, companyLogo: '', + hasExpiryDate: true, + expiryDate: new Date(new Date().setDate(new Date().getDate() + 49)), hasSalaryRange: true, minSalary: 75000, maxSalary: 95000, @@ -253,6 +269,7 @@ let jobs = [ currency: Currency.USD, companyLogo: '', hasSalaryRange: true, + hasExpiryDate: false, hasExperiencerange: false, minSalary: 25000, maxSalary: 50000, @@ -271,6 +288,8 @@ let jobs = [ type: EmployementType.Contract, workMode: WorkMode.remote, currency: Currency.USD, + hasExpiryDate: true, + expiryDate: new Date(new Date().setDate(new Date().getDate() + 49)), hasExperiencerange: true, minExperience: 1, maxExperience: 2, @@ -339,6 +358,8 @@ async function seedJobs() { city: faker.location.city(), address: faker.location.city(), hasExperiencerange: j.hasExperiencerange, + hasExpiryDate: j.hasExpiryDate, + expiryDate: j.expiryDate, minExperience: j.minExperience, maxExperience: j.maxExperience, companyLogo: '/main.svg', diff --git a/src/actions/corn.ts b/src/actions/corn.ts new file mode 100644 index 00000000..15b7b2d5 --- /dev/null +++ b/src/actions/corn.ts @@ -0,0 +1,20 @@ +// lib/cron.ts +import cron from 'node-cron'; +import { updateExpiredJobs } from './job.action'; + +let cronJobInitialized = false; + +export const startCronJob = () => { + if (!cronJobInitialized) { + cronJobInitialized = true; + + // Schedule the job to run at midnight (12:00 AM) every day + cron.schedule('0 0 * * *', async () => { + try { + await updateExpiredJobs(); + } catch (error) { + console.error('Error updating expired jobs:', error); + } + }); + } +}; diff --git a/src/actions/job.action.ts b/src/actions/job.action.ts index 8b835601..2f946989 100644 --- a/src/actions/job.action.ts +++ b/src/actions/job.action.ts @@ -53,6 +53,8 @@ export const createJob = withServerActionAsyncCatcher< description, hasSalaryRange, hasExperiencerange, + hasExpiryDate, + expiryDate, maxSalary, minExperience, maxExperience, @@ -65,6 +67,8 @@ export const createJob = withServerActionAsyncCatcher< description, hasExperiencerange, minExperience, + expiryDate, + hasExpiryDate, maxExperience, skills, companyName, @@ -111,6 +115,7 @@ export const getAllJobs = withServerActionAsyncCatcher< orderBy: [orderBy], where: { isVerifiedJob: true, + expired: false, ...filterQueries, }, select: { @@ -124,6 +129,8 @@ export const getAllJobs = withServerActionAsyncCatcher< hasExperiencerange: true, minExperience: true, maxExperience: true, + hasExpiryDate: true, + expiryDate: true, skills: true, address: true, workMode: true, @@ -164,6 +171,7 @@ export const getRecommendedJobs = withServerActionAsyncCatcher< category: category, id: { not: id }, isVerifiedJob: true, + expired: false, }, orderBy: { postedAt: 'desc', @@ -194,6 +202,7 @@ export const getRecommendedJobs = withServerActionAsyncCatcher< const fallbackJobs = await prisma.job.findMany({ where: { id: { not: id }, + expired: false, }, orderBy: { postedAt: 'desc', @@ -239,7 +248,7 @@ export const getJobById = withServerActionAsyncCatcher< const result = JobByIdSchema.parse(data); const { id } = result; const job = await prisma.job.findFirst({ - where: { id }, + where: { id, expired: false }, select: { id: true, title: true, @@ -252,6 +261,8 @@ export const getJobById = withServerActionAsyncCatcher< category: true, city: true, hasExperiencerange: true, + expiryDate: true, + hasExpiryDate: true, minExperience: true, maxExperience: true, skills: true, @@ -272,6 +283,7 @@ export const getJobById = withServerActionAsyncCatcher< export const getCityFilters = async () => { const response = await prisma.job.findMany({ select: { + expired: false, city: true, }, }); @@ -284,8 +296,9 @@ export const getCityFilters = async () => { export const getRecentJobs = async () => { try { const recentJobs = await prisma.job.findMany({ - where: { + where: { isVerifiedJob: true, + expired: false, }, orderBy: { postedAt: 'desc', @@ -354,3 +367,19 @@ export const updateJob = withServerActionAsyncCatcher< additonal ).serialize(); }); + +export async function updateExpiredJobs() { + const currentDate = new Date(); + + await prisma.job.updateMany({ + where: { + hasExpiryDate: true, + expiryDate: { + lt: currentDate, + }, + }, + data: { + expired: true, + }, + }); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index bd78e16e..c0a3335e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,11 @@ +import { startCronJob } from '@/actions/corn'; import Faqs from '@/components/Faqs'; import HeroSection from '@/components/hero-section'; import { JobLanding } from '@/components/job-landing'; import Testimonials from '@/components/Testimonials'; const HomePage = async () => { + startCronJob(); return (
diff --git a/src/components/job-form.tsx b/src/components/job-form.tsx index 467aa4fb..2bbea9b2 100644 --- a/src/components/job-form.tsx +++ b/src/components/job-form.tsx @@ -25,7 +25,12 @@ import { import { Button } from './ui/button'; import { Input } from './ui/input'; import { useToast } from './ui/use-toast'; -import { Calendar, LucideRocket, MailOpenIcon, X } from 'lucide-react'; +import { + Calendar as CalendarIcon, + LucideRocket, + MailOpenIcon, + X, +} from 'lucide-react'; import DescriptionEditor from './DescriptionEditor'; import Image from 'next/image'; import { FaFileUpload } from 'react-icons/fa'; @@ -33,6 +38,7 @@ import { Switch } from './ui/switch'; import { Label } from './ui/label'; import dynamic from 'next/dynamic'; import { uploadFileAction } from '@/actions/upload-to-cdn'; +import { format } from 'date-fns'; const DynamicGmapsAutoSuggest = dynamic(() => import('./gmaps-autosuggest'), { ssr: false, @@ -43,6 +49,8 @@ import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; import APP_PATHS from '@/config/path.config'; import { SkillsCombobox } from './skills-combobox'; +import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; +import { Calendar } from '@/components/ui/calendar'; const PostJobForm = () => { const session = useSession(); @@ -72,6 +80,8 @@ const PostJobForm = () => { workMode: 'remote', type: EmployementType.Full_time, category: 'design', + hasExpiryDate: true, + expiryDate: undefined, hasSalaryRange: true, minSalary: 0, maxSalary: 0, @@ -187,6 +197,7 @@ const PostJobForm = () => { }; const watchHasSalaryRange = form.watch('hasSalaryRange'); const watchHasExperienceRange = form.watch('hasExperiencerange'); + const watchHasExpiryDate = form.watch('hasExpiryDate'); const [comboBoxSelectedValues, setComboBoxSelectedValues] = useState< string[] @@ -207,7 +218,7 @@ const PostJobForm = () => {
- +

Posted for

30 days

@@ -513,7 +524,76 @@ const PostJobForm = () => { )}
+
+ + ( + + + + + + Does this job posting have an expiry date? + + + )} + /> + {watchHasExpiryDate && ( +
+ ( + +
+ + + + + + + { + field.onChange(date); // Update the field value with the selected date + }} + aria-disabled={(date: any) => + date > new Date() || + date < new Date('1900-01-01') + } + /> + + + + +
+ )} + /> +
+ )} +
Location ; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + , + IconRight: () => , + }} + {...props} + /> + ); +} +Calendar.displayName = 'Calendar'; + +export { Calendar }; diff --git a/src/lib/validators/jobs.validator.ts b/src/lib/validators/jobs.validator.ts index 5fa3abf6..8cfc3b85 100644 --- a/src/lib/validators/jobs.validator.ts +++ b/src/lib/validators/jobs.validator.ts @@ -39,6 +39,10 @@ export const JobPostSchema = z .number({ message: 'Max Experience must be a number' }) .nonnegative() .optional(), + hasExpiryDate: z.boolean(), + expiryDate: z.coerce + .date({ message: 'Expiry date is required' }) + .optional(), workMode: z.nativeEnum(WorkMode, { message: 'Work mode is required', }), @@ -69,6 +73,23 @@ export const JobPostSchema = z } } + if (data.hasExpiryDate) { + if (!data.expiryDate) { + return ctx.addIssue({ + message: 'Expiry date is required ', + path: ['expiryDate'], + code: z.ZodIssueCode.custom, + }); + } + if (data.expiryDate <= new Date()) { + return ctx.addIssue({ + message: 'Expiry date cannot be in the past', + path: ['expiryDate'], + code: z.ZodIssueCode.custom, + }); + } + } + if (data.hasExperiencerange) { if (!data.minExperience) { return ctx.addIssue({