From 1f42633f805fa723fd2ee14d29b4087b445763e5 Mon Sep 17 00:00:00 2001 From: Dev Sharma Date: Thu, 24 Oct 2024 00:37:42 +0530 Subject: [PATCH 1/4] feat: admin can add hr --- .../20241023182559_add_hr/migration.sql | 43 +++ .../20241023184042_spell_error/migration.sql | 10 + prisma/schema.prisma | 12 +- src/actions/hr.actions.ts | 77 +++++ src/app/admin/add-hr/page.tsx | 16 + src/components/AddHRForm.tsx | 284 ++++++++++++++++++ src/components/HRPassword.tsx | 80 +++++ src/config/path.config.ts | 1 + src/lib/constant/app.constant.ts | 7 + src/lib/randomPassword.ts | 10 + src/lib/validators/hr.validator.ts | 11 + src/middleware.ts | 3 + 12 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20241023182559_add_hr/migration.sql create mode 100644 prisma/migrations/20241023184042_spell_error/migration.sql create mode 100644 src/actions/hr.actions.ts create mode 100644 src/app/admin/add-hr/page.tsx create mode 100644 src/components/AddHRForm.tsx create mode 100644 src/components/HRPassword.tsx create mode 100644 src/lib/randomPassword.ts create mode 100644 src/lib/validators/hr.validator.ts 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 9310de33..76670b3a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,8 +29,10 @@ model User { oauthId String? blockedByAdmin DateTime? - onBoard Boolean @default(false) + onBoard Boolean @default(false) bookmark Bookmark[] + companyId String? @unique + company Company? @relation(fields: [companyId], references: [id]) } enum OauthProvider { @@ -122,6 +124,14 @@ model Project { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +model Company { + id String @id @default(cuid()) + companyName String + companyLogo String? + companyBio String + user User? +} + enum ProjectStack { GO PYTHON diff --git a/src/actions/hr.actions.ts b/src/actions/hr.actions.ts new file mode 100644 index 00000000..6775b0f0 --- /dev/null +++ b/src/actions/hr.actions.ts @@ -0,0 +1,77 @@ +'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, + }, + }); + + const user = await prisma.user.create({ + data: { + email: email, + password: hashedPassword, + isVerified: true, + name: name, + role: 'HR', + companyId: company.id, + }, + }); + + 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..78fe88a0 --- /dev/null +++ b/src/components/AddHRForm.tsx @@ -0,0 +1,284 @@ +'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, +} 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 +

+ + {/* 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..f5d47667 --- /dev/null +++ b/src/components/HRPassword.tsx @@ -0,0 +1,80 @@ +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 c8efde2a..6902bcf3 100644 --- a/src/config/path.config.ts +++ b/src/config/path.config.ts @@ -20,5 +20,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 f854f9a5..173ac096 100644 --- a/src/lib/constant/app.constant.ts +++ b/src/lib/constant/app.constant.ts @@ -35,6 +35,13 @@ export const adminNavbar = [ roleRequired: ['ADMIN', 'HR'], 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..7a736abc --- /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().email('Invalid email').min(1, 'Email is required'), + 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' && From aa9986baa68f5a55400b1b3543754e0188e029de Mon Sep 17 00:00:00 2001 From: Dev Sharma Date: Thu, 24 Oct 2024 00:50:13 +0530 Subject: [PATCH 2/4] refactor --- prisma/schema.prisma | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 76670b3a..5e828b7c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,10 +29,11 @@ model User { oauthId String? blockedByAdmin DateTime? - onBoard Boolean @default(false) - bookmark Bookmark[] - companyId String? @unique - company Company? @relation(fields: [companyId], references: [id]) + onBoard Boolean @default(false) + bookmark Bookmark[] + + companyId String? @unique + company Company? @relation(fields: [companyId], references: [id]) } enum OauthProvider { @@ -96,7 +97,6 @@ model Bookmark { user User @relation(fields: [userId],references: [id],onDelete: Cascade) } - model Experience { id Int @id @default(autoincrement()) companyName String @@ -125,11 +125,11 @@ model Project { } model Company { - id String @id @default(cuid()) - companyName String - companyLogo String? - companyBio String - user User? + id String @id @default(cuid()) + companyName String + companyLogo String? + companyBio String + user User? } enum ProjectStack { From 26c548b1c58d2734e06647d1f8dc208835fe5ce6 Mon Sep 17 00:00:00 2001 From: Dev Sharma Date: Fri, 1 Nov 2024 22:23:22 +0530 Subject: [PATCH 3/4] fixed build --- prisma/schema.prisma | 7 ------- src/actions/hr.actions.ts | 1 + 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 52e6e348..3a126f38 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -159,13 +159,6 @@ model Project { isFeature Boolean @default(false) } -model Company { - id String @id @default(cuid()) - companyName String - companyLogo String? - companyBio String - user User? -} enum ProjectStack { GO diff --git a/src/actions/hr.actions.ts b/src/actions/hr.actions.ts index 6775b0f0..ba9a2e8d 100644 --- a/src/actions/hr.actions.ts +++ b/src/actions/hr.actions.ts @@ -47,6 +47,7 @@ export const createHR = withSession< companyName: companyName, companyBio: companyBio, companyLogo: companyLogo, + companyEmail: email, }, }); From af8187c43c25eda5c6bce9e30463786bc29a50d2 Mon Sep 17 00:00:00 2001 From: Dev Sharma Date: Sun, 3 Nov 2024 09:41:25 +0530 Subject: [PATCH 4/4] fix error messages --- src/actions/hr.actions.ts | 2 +- src/components/AddHRForm.tsx | 33 +++++++++++++++++++----------- src/components/HRPassword.tsx | 5 +++-- src/lib/validators/hr.validator.ts | 2 +- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/actions/hr.actions.ts b/src/actions/hr.actions.ts index ba9a2e8d..0535b237 100644 --- a/src/actions/hr.actions.ts +++ b/src/actions/hr.actions.ts @@ -55,10 +55,10 @@ export const createHR = withSession< data: { email: email, password: hashedPassword, - isVerified: true, name: name, role: 'HR', companyId: company.id, + emailVerified: new Date(), }, }); diff --git a/src/components/AddHRForm.tsx b/src/components/AddHRForm.tsx index 78fe88a0..5cd2ec58 100644 --- a/src/components/AddHRForm.tsx +++ b/src/components/AddHRForm.tsx @@ -9,6 +9,7 @@ import { FormField, FormItem, FormLabel, + FormMessage, } from '@/components/ui/form'; import { useToast } from './ui/use-toast'; import { uploadFileAction } from '@/actions/upload-to-cdn'; @@ -155,14 +156,17 @@ const AddHRForm = () => { name="name" render={({ field }) => ( - Name * + + Name * + + )} /> @@ -171,29 +175,32 @@ const AddHRForm = () => { name="email" render={({ field }) => ( - Email * + + Email * + + )} />
-

- Company +

+ Company Details

{/* Logo Upload Section */}
{previewImg ? ( @@ -226,7 +233,7 @@ const AddHRForm = () => { accept="image/*" onChange={handleFileChange} /> -

+

Click the avatar to change or upload your company logo

@@ -239,22 +246,23 @@ const AddHRForm = () => { name="companyName" render={({ field }) => ( - + Company Name * + )} />
-
@@ -264,6 +272,7 @@ const AddHRForm = () => { onDescriptionChange={handleDescriptionChange} placeholder={'Tell us about your company'} /> +
diff --git a/src/components/HRPassword.tsx b/src/components/HRPassword.tsx index f5d47667..acc8f913 100644 --- a/src/components/HRPassword.tsx +++ b/src/components/HRPassword.tsx @@ -34,14 +34,15 @@ const HRPassword = ({ } return ( <> -
+

HR Created Successfully! Below are the details

-
+

Password