diff --git a/prisma/migrations/20241023182559_add_hr/migration.sql b/prisma/migrations/20241023182559_add_hr/migration.sql new file mode 100644 index 00000000..03e24c9e --- /dev/null +++ b/prisma/migrations/20241023182559_add_hr/migration.sql @@ -0,0 +1,43 @@ +/* + Warnings: + + - A unique constraint covering the columns `[companyId]` on the table `User` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "ProjectStack" AS ENUM ('GO', 'PYTHON', 'MERN', 'NEXTJS', 'AI_GPT_APIS', 'SPRINGBOOT', 'OTHERS'); + +-- DropForeignKey +ALTER TABLE "Experience" DROP CONSTRAINT "Experience_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Project" DROP CONSTRAINT "Project_userId_fkey"; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "projectThumbnail" TEXT, +ADD COLUMN "stack" "ProjectStack" NOT NULL DEFAULT 'OTHERS'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "companyId" TEXT; + +-- CreateTable +CREATE TABLE "Company" ( + "id" TEXT NOT NULL, + "compangName" TEXT NOT NULL, + "companyLogo" TEXT, + "companyBio" TEXT NOT NULL, + + CONSTRAINT "Company_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_companyId_key" ON "User"("companyId"); + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Experience" ADD CONSTRAINT "Experience_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20241023184042_spell_error/migration.sql b/prisma/migrations/20241023184042_spell_error/migration.sql new file mode 100644 index 00000000..666596f9 --- /dev/null +++ b/prisma/migrations/20241023184042_spell_error/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `compangName` on the `Company` table. All the data in the column will be lost. + - Added the required column `companyName` to the `Company` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Company" DROP COLUMN "compangName", +ADD COLUMN "companyName" TEXT NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1cc0363b..3a126f38 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -159,6 +159,7 @@ model Project { isFeature Boolean @default(false) } + enum ProjectStack { GO PYTHON diff --git a/src/actions/hr.actions.ts b/src/actions/hr.actions.ts new file mode 100644 index 00000000..0535b237 --- /dev/null +++ b/src/actions/hr.actions.ts @@ -0,0 +1,78 @@ +'use server'; + +import prisma from '@/config/prisma.config'; +import { ErrorHandler } from '@/lib/error'; +import { withSession } from '@/lib/session'; +import { SuccessResponse } from '@/lib/success'; +import { HRPostSchema, HRPostSchemaType } from '@/lib/validators/hr.validator'; +import { ServerActionReturnType } from '@/types/api.types'; +import bcryptjs from 'bcryptjs'; +import { PASSWORD_HASH_SALT_ROUNDS } from '@/config/auth.config'; +import { generateRandomPassword } from '@/lib/randomPassword'; + +type HRReturnType = { + password: string; + userId: string; +}; + +export const createHR = withSession< + HRPostSchemaType, + ServerActionReturnType +>(async (session, data) => { + if (!session || !session?.user?.id || session.user.role !== 'ADMIN') { + throw new ErrorHandler('Not Authrised', 'UNAUTHORIZED'); + } + + const result = HRPostSchema.safeParse(data); + if (result.error) { + throw new ErrorHandler('Invalid Data', 'BAD_REQUEST'); + } + + const { companyBio, companyLogo, companyName, email, name } = result.data; + + const userExist = await prisma.user.findFirst({ + where: { email: email }, + }); + + if (userExist) + throw new ErrorHandler('User with this email already exist', 'BAD_REQUEST'); + const password = generateRandomPassword(); + const hashedPassword = await bcryptjs.hash( + password, + PASSWORD_HASH_SALT_ROUNDS + ); + const { user } = await prisma.$transaction(async () => { + const company = await prisma.company.create({ + data: { + companyName: companyName, + companyBio: companyBio, + companyLogo: companyLogo, + companyEmail: email, + }, + }); + + const user = await prisma.user.create({ + data: { + email: email, + password: hashedPassword, + name: name, + role: 'HR', + companyId: company.id, + emailVerified: new Date(), + }, + }); + + return { user }; + }); + + const returnObj = { + password, + userId: user.id, + }; + + return new SuccessResponse( + 'HR created successfully.', + 201, + returnObj + ).serialize(); +}); diff --git a/src/app/admin/add-hr/page.tsx b/src/app/admin/add-hr/page.tsx new file mode 100644 index 00000000..5a38e684 --- /dev/null +++ b/src/app/admin/add-hr/page.tsx @@ -0,0 +1,16 @@ +import AddHRForm from '@/components/AddHRForm'; +import React from 'react'; + +const page = () => { + return ( +
+
+

Add HR

+
+ + +
+ ); +}; + +export default page; diff --git a/src/components/AddHRForm.tsx b/src/components/AddHRForm.tsx new file mode 100644 index 00000000..5cd2ec58 --- /dev/null +++ b/src/components/AddHRForm.tsx @@ -0,0 +1,293 @@ +'use client'; +import { HRPostSchema, HRPostSchemaType } from '@/lib/validators/hr.validator'; +import { zodResolver } from '@hookform/resolvers/zod'; +import React, { useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { useToast } from './ui/use-toast'; +import { uploadFileAction } from '@/actions/upload-to-cdn'; +import { createHR } from '@/actions/hr.actions'; +import { Input } from './ui/input'; +import { Button } from './ui/button'; +import DescriptionEditor from './DescriptionEditor'; +import Image from 'next/image'; +import { FaFileUpload } from 'react-icons/fa'; +import { X } from 'lucide-react'; +import HRPassword from './HRPassword'; + +const AddHRForm = () => { + const [file, setFile] = useState(null); + const [previewImg, setPreviewImg] = useState(null); + const [password, setPassword] = useState(null); + + const { toast } = useToast(); + const companyLogoImg = useRef(null); + const form = useForm({ + resolver: zodResolver(HRPostSchema), + defaultValues: { + name: '', + email: '', + companyName: '', + companyBio: '', + companyLogo: '/main.svg', + }, + }); + + const submitImage = async (file: File | null) => { + if (!file) return; + + const formData = new FormData(); + formData.append('file', file); + + try { + const uniqueFileName = `${Date.now()}-${file.name}`; + formData.append('uniqueFileName', uniqueFileName); + + const res = await uploadFileAction(formData, 'webp'); + if (!res) { + throw new Error('Failed to upload image'); + } + + const uploadRes = res; + return uploadRes.url; + } catch (error) { + console.error('Image upload failed:', error); + } + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const selectedFile = e.target.files ? e.target.files[0] : null; + if (!selectedFile) { + return; + } + if (!selectedFile.type.includes('image')) { + toast({ + title: + 'Invalid file format. Please upload an image file (e.g., .png, .jpg, .jpeg, .svg ) for the company logo', + variant: 'destructive', + }); + return; + } + const reader = new FileReader(); + reader.onload = () => { + if (companyLogoImg.current) { + companyLogoImg.current.src = reader.result as string; + } + setPreviewImg(reader.result as string); + }; + reader.readAsDataURL(selectedFile); + if (selectedFile) { + setFile(selectedFile); + } + }; + const clearLogoImage = () => { + const fileInput = document.getElementById('fileInput') as HTMLInputElement; + + if (fileInput) { + fileInput.value = ''; + } + setPreviewImg(null); + setFile(null); + }; + const createHRHandler = async (data: HRPostSchemaType) => { + try { + data.companyLogo = (await submitImage(file)) ?? '/main.svg'; + const response = await createHR(data); + + if (!response.status) { + return toast({ + title: response.message || 'Error', + variant: 'destructive', + }); + } + toast({ + title: response.message, + variant: 'success', + }); + setPreviewImg(null); + if (response.additional?.password) + setPassword(response.additional?.password); + form.reset(form.formState.defaultValues); + } catch (_error) { + toast({ + title: 'Something went wrong will creating HR', + description: 'Internal server error', + variant: 'destructive', + }); + } + }; + const handleDescriptionChange = (fieldName: any, value: String) => { + form.setValue(fieldName, value); + }; + + const handleClick = () => { + const fileInput = document.getElementById('fileInput') as HTMLInputElement; + + if (fileInput) { + fileInput.click(); + } + }; + + const reset = () => { + setPassword(''); + form.reset(); + }; + return ( +
+
+ {!password && ( +
+ +
+

HR Details

+ + ( + + + Name * + + + + + + + )} + /> + ( + + + Email * + + + + + + + )} + /> +
+ +
+

+ Company Details +

+ + {/* Logo Upload Section */} +
+
+
+ {previewImg ? ( + Company Logo + ) : ( + + )} +
+ {previewImg && ( + + )} +
+ +

+ Click the avatar to change or upload your company logo +

+
+ + {/* Company Name and Email Fields */} + +
+ ( + + + Company Name * + + + + + + + )} + /> +
+ +
+
+ + +
+
+
+
+ +
+
+ + )} + +
+
+ ); +}; + +export default AddHRForm; diff --git a/src/components/HRPassword.tsx b/src/components/HRPassword.tsx new file mode 100644 index 00000000..acc8f913 --- /dev/null +++ b/src/components/HRPassword.tsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { Input } from './ui/input'; +import { Button } from './ui/button'; +import { Copy, Check, Eye, EyeOff } from 'lucide-react'; +import { useToast } from './ui/use-toast'; + +const HRPassword = ({ + password, + reset, +}: { + password: string | null; + reset: () => void; +}) => { + const [isCopied, setIsCopied] = useState(false); + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const { toast } = useToast(); + const handleCopyClick = () => { + if (password) { + window.navigator.clipboard.writeText(password); + setIsCopied(true); + toast({ + variant: 'success', + title: 'Password Copied to clipboard!', + }); + } + }; + const handleResetClick = () => { + reset(); + setIsPasswordVisible(false); + setIsCopied(false); + }; + if (!password) { + return null; + } + return ( + <> +
+

HR Created Successfully! Below are the details

+
+

Password

+
+ +
+ + +
+
+
+
+
+ +
+ + ); +}; + +export default HRPassword; diff --git a/src/config/path.config.ts b/src/config/path.config.ts index 31ebd971..a1b8b599 100644 --- a/src/config/path.config.ts +++ b/src/config/path.config.ts @@ -21,5 +21,6 @@ const APP_PATHS = { RESUME: '/profile/resume', EXPERIENCE: '/profile/experience', SKILLS: '/profile/skills', + ADD_HR: '/admin/add-hr', }; export default APP_PATHS; diff --git a/src/lib/constant/app.constant.ts b/src/lib/constant/app.constant.ts index 8c500b92..8b7355a6 100644 --- a/src/lib/constant/app.constant.ts +++ b/src/lib/constant/app.constant.ts @@ -34,6 +34,13 @@ export const adminNavbar = [ roleRequired: ['ADMIN'], icon: PackageSearch, }, + { + id: 4, + label: 'Add HR', + path: APP_PATHS.ADD_HR, + roleRequired: ['ADMIN'], + icon: PackageSearch, + }, ]; export const userProfileNavbar = [ { id: 1, label: 'My Account', path: APP_PATHS.PROFILE }, diff --git a/src/lib/randomPassword.ts b/src/lib/randomPassword.ts new file mode 100644 index 00000000..f0f1c1f5 --- /dev/null +++ b/src/lib/randomPassword.ts @@ -0,0 +1,10 @@ +export function generateRandomPassword(): string { + const chars = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#!$'; + let password = ''; + for (let i = 0; i < 8; i++) { + const randomIndex = Math.floor(Math.random() * chars.length); + password += chars[randomIndex]; + } + return password; +} diff --git a/src/lib/validators/hr.validator.ts b/src/lib/validators/hr.validator.ts new file mode 100644 index 00000000..c1caa866 --- /dev/null +++ b/src/lib/validators/hr.validator.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const HRPostSchema = z.object({ + name: z.string().min(1, 'Name is required'), + email: z.string().min(1, 'Email is required').email('Invalid email'), + companyBio: z.string().min(1, 'Company Bio is required'), + companyLogo: z.string().min(1, 'Company Logo is Required'), + companyName: z.string().min(1, 'Company Name is Required'), +}); + +export type HRPostSchemaType = z.infer; diff --git a/src/middleware.ts b/src/middleware.ts index 3e578f39..1387d604 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -15,6 +15,9 @@ export async function middleware(req: NextRequest) { ) { return NextResponse.redirect(new URL('/', req.url)); } + if (pathname === '/admin/add-hr' && token?.role !== 'ADMIN') { + return NextResponse.redirect(new URL('/', req.url)); + } if ( pathname !== '/create-profile' && token?.role === 'USER' &&