diff --git a/prisma/migrations/20241007041046_added_deleted_field_job/migration.sql b/prisma/migrations/20241007041046_added_deleted_field_job/migration.sql new file mode 100644 index 00000000..3fd0e579 --- /dev/null +++ b/prisma/migrations/20241007041046_added_deleted_field_job/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Job" ADD COLUMN "deleted" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a1082e84..41852a74 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,6 +78,7 @@ model Job { minExperience Int? maxExperience Int? isVerifiedJob Boolean @default(false) @map("is_verified_job") + deleted Boolean @default(false) postedAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/actions/job.action.ts b/src/actions/job.action.ts index 2f946989..4948a62a 100644 --- a/src/actions/job.action.ts +++ b/src/actions/job.action.ts @@ -1,9 +1,14 @@ 'use server'; import prisma from '@/config/prisma.config'; import { withServerActionAsyncCatcher } from '@/lib/async-catch'; +import { withSession } from '@/lib/session'; import { ErrorHandler } from '@/lib/error'; import { SuccessResponse } from '@/lib/success'; import { + ApproveJobSchema, + ApproveJobSchemaType, + deleteJobByIdSchema, + DeleteJobByIdSchemaType, JobByIdSchema, JobByIdSchemaType, JobPostSchema, @@ -24,11 +29,22 @@ import { getAllRecommendedJobs, getJobType, } from '@/types/jobs.types'; +import { withAdminServerAction } from '@/lib/admin'; +import { revalidatePath } from 'next/cache'; type additional = { isVerifiedJob: boolean; }; -export const createJob = withServerActionAsyncCatcher< + +type deletedJob = { + deletedJobID: string; +}; //TODO: Convert it to generic type that returns JobID Only; + +type ApprovedJobID = { + jobId: string; +}; + +export const createJob = withSession< JobPostSchemaType, ServerActionReturnType >(async (data) => { @@ -92,10 +108,10 @@ export const createJob = withServerActionAsyncCatcher< return new SuccessResponse(message, 201, additonal).serialize(); }); -export const getAllJobs = withServerActionAsyncCatcher< +export const getAllJobs = withSession< JobQuerySchemaType, ServerActionReturnType ->(async (data) => { +>(async (session, data) => { if (data?.workmode && !Array.isArray(data?.workmode)) { data.workmode = Array.of(data?.workmode); } @@ -109,14 +125,19 @@ export const getAllJobs = withServerActionAsyncCatcher< data.city = Array.of(data?.city); } const result = JobQuerySchema.parse(data); + const isAdmin = session.user.role === 'ADMIN'; const { filterQueries, orderBy, pagination } = getJobFilters(result); const queryJobsPromise = prisma.job.findMany({ ...pagination, orderBy: [orderBy], where: { - isVerifiedJob: true, - expired: false, - ...filterQueries, + ...(isAdmin + ? { ...filterQueries } + : { + isVerifiedJob: true, + ...filterQueries, + expired: false, + }), }, select: { id: true, @@ -139,6 +160,8 @@ export const getAllJobs = withServerActionAsyncCatcher< maxSalary: true, postedAt: true, companyLogo: true, + isVerifiedJob: true, + deleted: true, }, }); const totalJobsPromise = prisma.job.count({ @@ -194,6 +217,7 @@ export const getRecommendedJobs = withServerActionAsyncCatcher< maxSalary: true, postedAt: true, skills: true, + isVerifiedJob: true, companyLogo: true, }, }); @@ -225,6 +249,7 @@ export const getRecommendedJobs = withServerActionAsyncCatcher< companyLogo: true, minExperience: true, maxExperience: true, + isVerifiedJob: true, category: true, }, }); @@ -272,6 +297,7 @@ export const getJobById = withServerActionAsyncCatcher< minSalary: true, maxSalary: true, postedAt: true, + isVerifiedJob: true, application: true, }, }); @@ -368,6 +394,47 @@ export const updateJob = withServerActionAsyncCatcher< ).serialize(); }); +export const deleteJobById = withServerActionAsyncCatcher< + DeleteJobByIdSchemaType, + ServerActionReturnType +>(async (data) => { + const result = deleteJobByIdSchema.parse(data); + const { id } = result; + const deletedJob = await prisma.job.update({ + where: { + id: id, + }, + data: { + deleted: true, + }, + }); + const deletedJobID = deletedJob.id; + revalidatePath('/manage'); + return new SuccessResponse('Job Deleted successfully', 200, { + deletedJobID, + }).serialize(); +}); + +export const approveJob = withAdminServerAction< + ApproveJobSchemaType, + ServerActionReturnType +>(async (session, data) => { + const result = ApproveJobSchema.safeParse(data); + if (!result.success) { + throw new Error(result.error.errors.toLocaleString()); + } + const { id } = result.data; + await prisma.job.update({ + where: { + id: id, + }, + data: { + isVerifiedJob: true, + }, + }); + revalidatePath('/manage'); + return new SuccessResponse('Job Approved', 200, { jobId: id }).serialize(); +}); export async function updateExpiredJobs() { const currentDate = new Date(); diff --git a/src/app/globals.css b/src/app/globals.css index 7a417889..df05297e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -313,7 +313,7 @@ html.dark.ql-toolbar.ql-snow { border-bottom: 1px solid #374151 !important; } -.ql-toolbar.ql-snow{ +.ql-toolbar.ql-snow { border: none !important; border-bottom: 1px solid #a9aaac !important; } diff --git a/src/app/manage/page.tsx b/src/app/manage/page.tsx new file mode 100644 index 00000000..afcddb1d --- /dev/null +++ b/src/app/manage/page.tsx @@ -0,0 +1,35 @@ +import JobManagement from '@/components/JobManagement'; +import { options } from '@/lib/auth'; +import { + JobQuerySchema, + JobQuerySchemaType, +} from '@/lib/validators/jobs.validator'; +import { getServerSession } from 'next-auth'; +import { redirect } from 'next/navigation'; +import React from 'react'; + +const ManageJob = async ({ + searchParams, +}: { + searchParams: JobQuerySchemaType; +}) => { + const parsedData = JobQuerySchema.safeParse(searchParams); + const server = await getServerSession(options); + if (!server?.user) { + redirect('/api/auth/signin'); + } else if (server.user.role !== 'ADMIN') { + redirect('/jobs'); + } + if (!(parsedData.success && parsedData.data)) { + console.error(parsedData.error); + redirect('/jobs'); + } + const searchParamss = parsedData.data; + return ( +
+ +
+ ); +}; + +export default ManageJob; diff --git a/src/components/ApproveJobDialog.tsx b/src/components/ApproveJobDialog.tsx new file mode 100644 index 00000000..2b2db842 --- /dev/null +++ b/src/components/ApproveJobDialog.tsx @@ -0,0 +1,57 @@ +'use client'; +import React from 'react'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from './ui/dialog'; +import { Button } from './ui/button'; + +const ApproveJobDialog = ({ + title, + description, + handleClick, +}: { + title: string; + description: string; + handleClick: () => void; +}) => { + return ( + <> + + + + Approve Now + + + + + {title} + {description} + + + + + + + + + + ); +}; + +export default ApproveJobDialog; diff --git a/src/components/JobManagement.tsx b/src/components/JobManagement.tsx new file mode 100644 index 00000000..a0e6918d --- /dev/null +++ b/src/components/JobManagement.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { getAllJobs } from '@/actions/job.action'; +import JobManagementHeader from './JobManagementHeader'; +import JobManagementTable from './JobManagementTable'; +import { JobQuerySchemaType } from '@/lib/validators/jobs.validator'; + +const JobManagement = async ({ + searchParams, +}: { + searchParams: JobQuerySchemaType; +}) => { + const jobs = await getAllJobs(searchParams); + if (!jobs.status) { + return
Error {jobs.message}
; + } + return ( +
+ + +
+ ); +}; +export default JobManagement; diff --git a/src/components/JobManagementHeader.tsx b/src/components/JobManagementHeader.tsx new file mode 100644 index 00000000..30035d79 --- /dev/null +++ b/src/components/JobManagementHeader.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import Link from 'next/link'; +import { Button } from './ui/button'; + +const JobManagementHeader = () => { + return ( + <> +
+
+

Active Job Posting

+ + View and Manage all active job posting. + +
+
+ +
+
+ + ); +}; + +export default JobManagementHeader; diff --git a/src/components/JobManagementTable.tsx b/src/components/JobManagementTable.tsx new file mode 100644 index 00000000..d8b1164f --- /dev/null +++ b/src/components/JobManagementTable.tsx @@ -0,0 +1,143 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from './ui/table'; +import { Edit, X } from 'lucide-react'; +import { getAllJobsAdditonalType } from '@/types/jobs.types'; +import { ServerActionReturnType } from '@/types/api.types'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { JobQuerySchemaType } from '@/lib/validators/jobs.validator'; +import { DEFAULT_PAGE, JOBS_PER_PAGE } from '@/config/app.config'; +import { Pagination, PaginationContent, PaginationItem } from './ui/pagination'; +import { + PaginationNextButton, + PaginationPreviousButton, +} from './pagination-client'; +import APP_PATHS from '@/config/path.config'; +import { PaginationPages } from './ui/paginator'; +import ApproveJobButton from './approveJobButton'; +import RemoveJobButton from './removeJobButton'; + +type props = { + searchParams: JobQuerySchemaType; + jobs: ServerActionReturnType; +}; + +const JobManagementTable = ({ jobs, searchParams }: props) => { + if (!jobs.status) { + return
Error {jobs.message}
; + } + + const totalPages = + Math.ceil((jobs.additional?.totalJobs || 0) / JOBS_PER_PAGE) || + DEFAULT_PAGE; + const currentPage = searchParams.page || DEFAULT_PAGE; + return ( + <> +
+ + + + Job Title + JobType + Location + isVerified + Actions + + + + {jobs.additional?.jobs?.map((job) => ( + + {job?.title} + {job?.workMode} + {job?.city} + + {job.isVerifiedJob ? ( + job.deleted ? ( + + Deleted + + ) : ( + + Approved + + ) + ) : ( + + )} + + + + + + + + + + + + + Are you absolutely sure? + + This action cannot be undone. This will Delete the + Selected JOB. + + + + + + + + + + + ))} + +
+ + + {totalPages ? ( + + + + ) : null} + + {totalPages ? ( + + + + ) : null} + + +
+ + ); +}; + +export default JobManagementTable; diff --git a/src/components/approveJobButton.tsx b/src/components/approveJobButton.tsx new file mode 100644 index 00000000..8ae8aff3 --- /dev/null +++ b/src/components/approveJobButton.tsx @@ -0,0 +1,35 @@ +'use client'; +import { approveJob } from '@/actions/job.action'; +import React from 'react'; +import ApproveJobDialog from './ApproveJobDialog'; +import { useToast } from './ui/use-toast'; + +const ApproveJobButton = ({ jobId }: { jobId: string }) => { + const { toast } = useToast(); + const handleApproveJob = async (jobId: string) => { + try { + const result = await approveJob({ id: jobId }); + if (result.status) { + toast({ title: result.message, variant: 'success' }); + } else { + toast({ variant: 'destructive', title: result.message }); + } + } catch (error) { + console.error(error); + toast({ title: 'An error occured' }); + } + }; + return ( +
+ { + handleApproveJob(jobId); + }} + /> +
+ ); +}; + +export default ApproveJobButton; diff --git a/src/components/job-form.tsx b/src/components/job-form.tsx index c84e9630..0bb247fa 100644 --- a/src/components/job-form.tsx +++ b/src/components/job-form.tsx @@ -219,19 +219,27 @@ const PostJobForm = () => {
-

Posted for

+

+ Posted for +

30 days

-

Emailed to

-

17,000 subscribers

+

+ Emailed to +

+

+ 17,000 subscribers +

-

Reach

+

+ Reach +

500,000+

@@ -626,7 +634,9 @@ const PostJobForm = () => { >
-

Job description

+

+ Job description +

{ + const { toast } = useToast(); + const handleDelete = async () => { + try { + const result = await deleteJobById({ id: jobId }); + if (result.status) { + toast({ title: result.message, variant: 'default' }); + } else { + toast({ title: result.message, variant: 'default' }); + } + } catch (error) { + console.error(error); + toast({ title: 'An Error occurred', variant: 'destructive' }); + } + }; + return ( + <> + + + ); +}; + +export default RemoveJobButton; diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 00000000..33a12099 --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)); +Table.displayName = 'Table'; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = 'TableHeader'; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = 'TableBody'; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0', + className + )} + {...props} + /> +)); +TableFooter.displayName = 'TableFooter'; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = 'TableRow'; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = 'TableHead'; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = 'TableCell'; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = 'TableCaption'; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/src/config/path.config.ts b/src/config/path.config.ts index 60bd264b..b8f59599 100644 --- a/src/config/path.config.ts +++ b/src/config/path.config.ts @@ -5,7 +5,7 @@ const APP_PATHS = { SIGNUP: '/signup', RESET_PASSWORD: '/reset-password', JOBS: '/jobs', - MANAGE_JOBS: '/jobs/manage', + MANAGE_JOBS: '/manage', CONTACT_US: 'mailto:vineetagarwal.now@gmail.com', TESTIMONIALS: '#testimonials', FAQS: '#faq', diff --git a/src/lib/admin.ts b/src/lib/admin.ts new file mode 100644 index 00000000..f1b5c9bb --- /dev/null +++ b/src/lib/admin.ts @@ -0,0 +1,25 @@ +import { getServerSession, Session } from 'next-auth'; +import { options } from './auth'; +import { ErrorHandler } from './error'; +import { withServerActionAsyncCatcher } from './async-catch'; + +// Added session also if we want to use ID +type withAdminServerActionType = ( + session: Session, + args?: T +) => Promise; + +export function withAdminServerAction( + serverAction: withAdminServerActionType +): (args?: T) => Promise { + return withServerActionAsyncCatcher(async (args?: T) => { + const session = await getServerSession(options); + if (!session || session.user.role !== 'ADMIN') { + throw new ErrorHandler( + 'You must be authenticated to access this resource.', + 'UNAUTHORIZED' + ); + } + return await serverAction(session, args); + }); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 746faac5..f5c44e4d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -149,6 +149,7 @@ export const options = { return session; }, }, + secret: process.env.NEXTAUTH_SECRET, session: { strategy: 'jwt', maxAge: AUTH_TOKEN_EXPIRATION_TIME, @@ -156,7 +157,4 @@ export const options = { jwt: { maxAge: AUTH_TOKEN_EXPIRATION_TIME, }, - pages: { - signIn: '/signin', - }, } satisfies NextAuthOptions; diff --git a/src/lib/validators/jobs.validator.ts b/src/lib/validators/jobs.validator.ts index 8cfc3b85..83f9385c 100644 --- a/src/lib/validators/jobs.validator.ts +++ b/src/lib/validators/jobs.validator.ts @@ -173,7 +173,17 @@ export const RecommendedJobSchema = z.object({ category: z.string().min(1, 'Job category is required'), }); +export const deleteJobByIdSchema = z.object({ + id: z.string().min(1, 'Job id is required'), +}); + +export const ApproveJobSchema = z.object({ + id: z.string().min(1, 'Job id is required'), +}); + export type JobByIdSchemaType = z.infer; export type RecommendedJobSchemaType = z.infer; export type JobPostSchemaType = z.infer; export type JobQuerySchemaType = z.infer; +export type DeleteJobByIdSchemaType = z.infer; +export type ApproveJobSchemaType = z.infer; diff --git a/src/types/jobs.types.ts b/src/types/jobs.types.ts index 572e3210..265f2ae8 100644 --- a/src/types/jobs.types.ts +++ b/src/types/jobs.types.ts @@ -16,7 +16,9 @@ export type JobType = { description: string | null; companyName: string; postedAt: Date; + isVerifiedJob?: Boolean; application?: string; + deleted?: Boolean; }; export type getAllJobsAdditonalType = { jobs: JobType[];