From 465f385ea688a738e336e3b7f95534242a88c882 Mon Sep 17 00:00:00 2001 From: Jediah Jireh Naicker <162832021+jediahjireh@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:11:31 +0200 Subject: [PATCH 01/12] feat: implement dual-user platform with candidate portal --- README.md | 1297 ++++++++++++++--- actions/admin.actions.ts | 149 +- actions/cv.actions.ts | 345 +++++ actions/queries/admin.queries.ts | 50 +- actions/queries/cv.queries.ts | 21 + actions/queries/user.queries.ts | 15 +- actions/stats.actions.ts | 143 +- actions/user.actions.ts | 62 + app/api/cv/analyze/route.ts | 66 + app/api/user/switch-role/route.ts | 65 + app/api/user/update-type/route.ts | 34 + app/app/_components/AnalyzeContent.tsx | 404 +++++ app/app/_components/CandidateInfo.tsx | 18 +- app/app/_components/CvDisplay.tsx | 6 +- app/app/_components/FileUpload.tsx | 42 +- app/app/_components/GenerateContent.tsx | 10 +- app/app/layout.tsx | 3 + app/app/onboarding/_components/Onboarding.tsx | 178 ++- app/app/onboarding/page.tsx | 2 +- app/app/page.tsx | 29 +- .../_components/layout/DashboardHeader.tsx | 2 + .../_components/layout/DashboardSidebar.tsx | 13 +- .../_components/statistics/UserStats.tsx | 2 + app/dashboard/analytics/page.tsx | 14 +- app/dashboard/cvs/_components/ContextMenu.tsx | 8 +- app/dashboard/layout.tsx | 12 + app/dashboard/page.tsx | 66 +- app/dashboard/settings/page.tsx | 103 +- .../templates/_components/Skeleton.tsx | 102 +- .../templates/_components/TemplateList.tsx | 278 ++-- app/dashboard/templates/page.tsx | 79 +- app/dashboard/users/page.tsx | 16 +- app/login/_components/GoogleButton.tsx | 4 +- app/login/_components/LinkedInButton.tsx | 4 +- app/login/_components/TestAccountButton.tsx | 196 +++ app/login/page.tsx | 29 +- .../_components/layout/CandidateLayout.tsx | 171 +++ app/portal/analyses/page.tsx | 241 +++ app/portal/analysis/[id]/page.tsx | 368 +++++ .../_components/CandidateDocumentsClient.tsx | 145 ++ app/portal/documents/page.tsx | 16 + app/portal/layout.tsx | 43 + .../_components/AllCVAnalysesClient.tsx | 166 +++ app/portal/organization/analyses/page.tsx | 16 + .../OrganizationAnalyticsClient.tsx | 229 +++ app/portal/organization/analytics/page.tsx | 16 + .../members/_components/MemberList.tsx | 224 +++ .../members/_components/MemberManagement.tsx | 173 +++ app/portal/organization/members/page.tsx | 57 + app/portal/page.tsx | 346 +++++ app/portal/progress/page.tsx | 379 +++++ app/portal/settings/page.tsx | 439 ++++++ app/recruiter/analytics/page.tsx | 68 + .../_components/RecruiterDocumentsClient.tsx | 147 ++ app/recruiter/documents/page.tsx | 16 + app/recruiter/layout.tsx | 43 + app/recruiter/page.tsx | 266 ++++ app/recruiter/settings/page.tsx | 390 +++++ app/recruiter/templates/page.tsx | 85 ++ app/recruiter/users/page.tsx | 43 + auth.ts | 143 +- components/global/AuthRedirect.tsx | 47 + components/global/InfoPanel.tsx | 56 +- components/global/LoaderButton.tsx | 4 +- components/global/Logo.tsx | 4 +- components/global/NavigationButtons.tsx | 4 +- components/global/RoleSwitcher.tsx | 149 ++ components/global/UserContextMenu.tsx | 212 ++- components/shared/AccessDeniedCard.tsx | 30 + components/shared/EmptyState.tsx | 42 + components/shared/ErrorCard.tsx | 30 + components/shared/LoadingSpinner.tsx | 22 + components/shared/StatsCard.tsx | 36 + components/ui/alert.tsx | 25 +- components/ui/button.tsx | 4 +- components/ui/progress.tsx | 29 + components/ui/scroll-area.tsx | 21 +- components/ui/sheet.tsx | 59 +- components/ui/skeleton.tsx | 6 +- constants/navigation.tsx | 74 +- constants/webcontent.tsx | 42 +- lib/redirectUtils.ts | 63 + lib/roleUtils.ts | 54 +- package-lock.json | 176 ++- package.json | 9 +- .../migration.sql | 86 ++ prisma/schema/company.prisma | 6 + prisma/schema/user.prisma | 40 + prisma/seed.ts | 792 ++++++++++ types/index.ts | 4 + types/next-auth.d.ts | 4 +- 91 files changed, 9533 insertions(+), 694 deletions(-) create mode 100644 actions/cv.actions.ts create mode 100644 actions/queries/cv.queries.ts create mode 100644 app/api/cv/analyze/route.ts create mode 100644 app/api/user/switch-role/route.ts create mode 100644 app/api/user/update-type/route.ts create mode 100644 app/app/_components/AnalyzeContent.tsx create mode 100644 app/login/_components/TestAccountButton.tsx create mode 100644 app/portal/_components/layout/CandidateLayout.tsx create mode 100644 app/portal/analyses/page.tsx create mode 100644 app/portal/analysis/[id]/page.tsx create mode 100644 app/portal/documents/_components/CandidateDocumentsClient.tsx create mode 100644 app/portal/documents/page.tsx create mode 100644 app/portal/layout.tsx create mode 100644 app/portal/organization/analyses/_components/AllCVAnalysesClient.tsx create mode 100644 app/portal/organization/analyses/page.tsx create mode 100644 app/portal/organization/analytics/_components/OrganizationAnalyticsClient.tsx create mode 100644 app/portal/organization/analytics/page.tsx create mode 100644 app/portal/organization/members/_components/MemberList.tsx create mode 100644 app/portal/organization/members/_components/MemberManagement.tsx create mode 100644 app/portal/organization/members/page.tsx create mode 100644 app/portal/page.tsx create mode 100644 app/portal/progress/page.tsx create mode 100644 app/portal/settings/page.tsx create mode 100644 app/recruiter/analytics/page.tsx create mode 100644 app/recruiter/documents/_components/RecruiterDocumentsClient.tsx create mode 100644 app/recruiter/documents/page.tsx create mode 100644 app/recruiter/layout.tsx create mode 100644 app/recruiter/page.tsx create mode 100644 app/recruiter/settings/page.tsx create mode 100644 app/recruiter/templates/page.tsx create mode 100644 app/recruiter/users/page.tsx create mode 100644 components/global/AuthRedirect.tsx create mode 100644 components/global/RoleSwitcher.tsx create mode 100644 components/shared/AccessDeniedCard.tsx create mode 100644 components/shared/EmptyState.tsx create mode 100644 components/shared/ErrorCard.tsx create mode 100644 components/shared/LoadingSpinner.tsx create mode 100644 components/shared/StatsCard.tsx create mode 100644 components/ui/progress.tsx create mode 100644 lib/redirectUtils.ts create mode 100644 prisma/migrations/20250811224508_add_company_type/migration.sql create mode 100644 prisma/seed.ts diff --git a/README.md b/README.md index 7d778d9..63ac7dc 100644 --- a/README.md +++ b/README.md @@ -1,201 +1,1012 @@ -# ProfilePrep +# ProfilePrep - AI-Powered CV Processing Platform + +## Table of Contents + +- [Introduction](#introduction) +- [Getting Started](#getting-started) +- [Core Architecture](#core-architecture) +- [User Systems & Permissions](#user-systems--permissions) +- [Feature Documentation](#feature-documentation) +- [Database & Data Models](#database--data-models) +- [AI Integration](#ai-integration) +- [Development Guide](#development-guide) +- [API Documentation](#api-documentation) +- [Deployment](#deployment) +- [Testing](#testing) +- [Troubleshooting](#troubleshooting) + +--- ## Introduction -ProfilePrep is an AI-powered platform designed to help recruiters transform standard CVs into compelling professional profiles. Our platform streamlines the profile optimisation process, allowing recruiters to focus on making connections rather than formatting documents. - - - -- [ProfilePrep](#profileprep) - - - [Introduction](#introduction) - - [Why ProfilePrep?](#why-profileprep) - - [Getting Started](#getting-started) - - [System Requirements](#system-requirements) - - [Installation](#installation) - - [Database Configuration](#database-configuration) - - [Running the Application](#running-the-application) - - [Core Features](#core-features) - - [CV Generation](#cv-generation) - - [How It Works](#how-it-works) - - [Key Benefits](#key-benefits) - - [CV Management](#cv-management) - - [Template System](#template-system) - - [User Management](#user-management) - - [Company Administration](#company-administration) - - [User Roles \& Permissions](#user-roles--permissions) - - [USER](#user) - - [ADMIN](#admin) - - [SUPERADMIN](#superadmin) - - [Troubleshooting](#troubleshooting--faqs) - - [Common Issues](#common-issues) - - [The CV generation takes too long or times out](#the-cv-generation-takes-too-long-or-times-out) - - [The generated CV is missing information from the original](#the-generated-cv-is-missing-information-from-the-original) - - [Template changes aren't reflected in generated CVs](#template-changes-arent-reflected-in-generated-cvs) - - [Future Enhancements](#future-enhancements) - - [1. Candidate Portal](#1-candidate-portal) - - [Features for Candidates](#features-for-candidates) - - [Integration with Recruiter Workflow](#integration-with-recruiter-workflow) - - [Implementation Timeline #1](#implementation-timeline-1) - - [2. Job Matching](#2-job-matching) - - [Initial Job Matching Features](#initial-job-matching-features) - - [Implementation Timeline #2](#implementation-timeline-2) - - [3. Advanced Matching Algorithm](#3-advanced-matching-algorithm) - - [Advanced Matching Features](#advanced-matching-features) - - [Technical Implementation](#technical-implementation) - - [Implementation Timeline #3](#implementation-timeline-3) - - [Contributing](#contributing) - - [License - Pending Decision](#license---pending-decision) +ProfilePrep is a dual-platform AI-powered application that serves two distinct user bases: ---- +**For Recruiters**: Transform standard CVs into compelling professional profiles with AI-powered document generation, template management, and client-ready outputs. -### Why ProfilePrep? +**For Candidates**: Analyze CVs against job descriptions to receive detailed feedback, ATS compatibility scores, and actionable improvement recommendations. -At ProfilePrep, we believe your candidate's profile should stand out in a competitive job market. Our application: +### Key Features -- **Saves Time**: Eliminates hours spent on manual CV formatting -- **Improves Consistency**: Ensures all candidate profiles follow company standards -- **Enhances Presentation**: Transforms ordinary CVs into polished, professional documents -- **Accelerates Placements**: Helps recruiters present candidates faster and more effectively - -The world doesn't need more generic CVs; it needs profiles that tell a story, showcase value, and make an impact. +- **Dual User Experience**: Complete parallel systems for recruiters and candidates +- **AI-Powered Processing**: Google Gemini integration for CV generation and analysis +- **Role-Based Access Control**: Comprehensive permissions system with company/organization scoping +- **Real-Time Analytics**: Live dashboards with performance metrics +- **Template System**: Customizable CV templates for consistent branding +- **Document Management**: Full lifecycle management of generated CVs and analyses --- ## Getting Started -### System Requirements +### Prerequisites -- Node.js 18.x or later -- npm +- Node.js 18+ and npm +- PostgreSQL database (or Supabase account) +- Google Gemini API key -### Installation +### Quick Installation -1. Clone the repository: +1. **Clone and Install**: - ```zsh - git clone https://github.com/NikeshCohen/ProfilePrep.git + ```bash + git clone cd ProfilePrep + npm install ``` -2. Install dependencies: +2. **Environment Setup**: - ```zsh - npm install + ```bash + cp .env.example .env + ``` + + Configure your `.env` file: + + ```env + # Database + DATABASE_URL="postgresql://user:password@host:port/database?pgbouncer=true" + DIRECT_URL="postgresql://user:password@host:port/database" + + # Google AI + GOOGLE_GENERATIVE_AI_API_KEY=your_google_ai_key + + # Authentication + AUTH_SECRET=your_auth_secret # Generate with: openssl rand -base64 32 + + # OAuth (Optional - for production) + AUTH_GOOGLE_ID=your_google_oauth_id + AUTH_GOOGLE_SECRET=your_google_oauth_secret + AUTH_LINKEDIN_ID=your_linkedin_oauth_id + AUTH_LINKEDIN_SECRET=your_linkedin_oauth_secret + ``` + +3. **Database Setup**: + + ```bash + npm run db:migrate + npm run db:seed # Includes demo data for development + ``` + +4. **Start Development**: + + ```bash + npm run dev ``` -3. Set up environment variables: +5. **Access Demo Accounts** (Development Only): + - Visit `http://localhost:3000/login` + - Use demo accounts provided below + +### Demo Account System + +**⚠️ IMPORTANT: Demo accounts are ONLY available in development mode (`NODE_ENV=development`)** + +#### Available Demo Accounts + +**Recruiter Accounts:** + +- **`demo@profileprep.com`** / `Demo2024!` - Basic recruiter (USER + RECRUITER) +- **`admin.demo@profileprep.com`** / `Admin2024!` - Admin recruiter (ADMIN + RECRUITER) + +**Candidate Accounts:** + +- **`candidate.demo@profileprep.com`** / `Candidate2024!` - Basic candidate (USER + CANDIDATE) +- **`admin.candidate.demo@profileprep.com`** / `AdminCandidate2024!` - Admin candidate (ADMIN + CANDIDATE) + +**System Admin:** - - Duplicate `.env.local.example` to create a `.env.local` file and insert your credentials. +- **`superadmin.demo@profileprep.com`** / `SuperAdmin2024!` - System-wide access -### Database Configuration +#### Role Switching System -Initialise the database with Prisma: +- **Availability**: Only for test accounts (`isTestAccount: true`) in development mode +- **Functionality**: Complete session switching between demo accounts +- **Security**: Completely disabled in production builds + +--- + +## Core Architecture + +### Technology Stack + +- **Frontend**: Next.js 15, React 19, TypeScript +- **Styling**: Tailwind CSS, shadcn/ui components +- **Backend**: Next.js App Router, Server Actions +- **Database**: PostgreSQL with Prisma ORM +- **Authentication**: NextAuth.js v5 (OAuth + Credentials) +- **AI**: Google Gemini API (2.0-flash-001 model) +- **State Management**: TanStack Query for client-side data +- **File Processing**: PDF parsing and text extraction +- **Deployment**: Vercel (recommended) + +### Project Structure ```zsh -npx prisma generate -npx prisma db push +ProfilePrep/ +├── app/ # Next.js App Router +│ ├── api/ # API routes +│ │ ├── auth/[...nextauth]/ # NextAuth.js endpoint +│ │ ├── cv/analyze/ # CV analysis API +│ │ └── user/ # User management APIs +│ ├── app/ # Main CV processing interface +│ ├── portal/ # Candidate dashboard & features +│ ├── recruiter/ # Recruiter dashboard & features +│ ├── dashboard/ # Admin dashboard (recruiters) +│ └── login/ # Authentication pages +├── actions/ # Server actions by domain +│ ├── cv.actions.ts # CV analysis & generation +│ ├── user.actions.ts # User management +│ ├── admin.actions.ts # Admin operations +│ └── queries/ # TanStack Query hooks +├── components/ # Reusable UI components +│ ├── ui/ # shadcn/ui components +│ ├── shared/ # Custom reusable components +│ └── global/ # App-wide components +├── prisma/ # Database schema & migrations +│ ├── schema/ # Modular schema files +│ └── migrations/ # Database migrations +├── lib/ # Utilities & configuration +│ ├── utils.ts # General utilities +│ ├── roleUtils.ts # Role-based access helpers +│ └── redirectUtils.ts # Navigation utilities +└── types/ # TypeScript type definitions ``` -### Running the Application +### Code Organization Patterns -For development: +1. **Server Actions**: Domain-specific actions in `actions/[domain].actions.ts` +2. **Client Queries**: TanStack Query hooks in `actions/queries/[domain].queries.ts` +3. **Page Components**: Server components in `app/[route]/page.tsx` +4. **Sub-components**: Client components in `app/[route]/_components/` +5. **Modular Database Schema**: Separate schema files in `prisma/schema/` -```zsh -npm run dev +--- + +## User Systems & Permissions + +ProfilePrep implements a comprehensive dual-user system with parallel organizational structures. + +### User Types + +- **RECRUITER**: Users who generate professional CV documents for clients +- **CANDIDATE**: Job seekers who analyze and optimize their CVs +- **TESTER**: Demo/testing accounts with role switching capabilities + +### Role Hierarchy + +- **USER**: Basic permissions within their user type and organization +- **ADMIN**: Organization-level administrative permissions +- **SUPERADMIN**: System-wide access across all organizations + +### Company Types + +- **RECRUITER**: Recruitment agencies and recruiting companies +- **CANDIDATE_ORG**: Candidate organizations (universities, career centers, etc.) + +### Permission Matrix + +| Feature | Regular User | Admin | Super Admin | +| -------------------- | ------------ | ----------------- | -------------- | +| **Recruiters** | | | | +| Generate CVs | ✅ (5/month) | ✅ (Unlimited) | ✅ (Unlimited) | +| View own documents | ✅ | ✅ | ✅ | +| Manage company users | ❌ | ✅ (Company) | ✅ (All) | +| Create templates | ❌ | ✅ (Company) | ✅ (All) | +| View analytics | ❌ | ✅ (Company) | ✅ (System) | +| **Candidates** | | | | +| Analyze CVs | ✅ (5/month) | ✅ (Unlimited) | ✅ (Unlimited) | +| View own analyses | ✅ | ✅ | ✅ | +| Manage org members | ❌ | ✅ (Organization) | ✅ (All) | +| View org analytics | ❌ | ✅ (Organization) | ✅ (System) | + +### Page Access Control + +**Recruiter Pages:** + +- `/recruiter` - Recruiter dashboard (All recruiter roles) +- `/recruiter/documents` - Document management (All recruiter roles) +- `/recruiter/settings` - Profile settings (All recruiter roles) +- `/dashboard/*` - Admin features (ADMIN+ recruiters only) + +**Candidate Pages:** + +- `/portal` - Candidate dashboard (All candidate roles) +- `/portal/documents` - Document management (All candidate roles) +- `/portal/analyses` - CV analysis history (All candidate roles) +- `/portal/progress` - Career tracking (All candidate roles) +- `/portal/settings` - Profile settings (All candidate roles) +- `/portal/organization/*` - Admin features (ADMIN+ candidates only) + +**Shared Pages:** + +- `/app` - CV processing interface (All authenticated users) + +--- + +## Feature Documentation + +### For Recruiters + +#### CV Document Generation + +**Location**: `/app` (recruiter mode) + +**Process Flow**: + +1. **Upload**: Upload candidate CVs (PDF or text format) +2. **Extract**: AI automatically parses CV content +3. **Enhance**: Add candidate details (name, location, salary, right to work) +4. **Generate**: AI creates professional client-ready document +5. **Customize**: Apply company templates if available +6. **Export**: Download as PDF or copy formatted content + +**Key Components**: + +- `FileUpload.tsx` - Drag & drop CV upload with PDF parsing +- `CandidateInfo.tsx` - Candidate details form +- `GenerateContent.tsx` - AI-powered document generation +- `CvDisplay.tsx` - Preview and download interface + +#### Document Management + +**Location**: `/recruiter/documents` + +**Features**: + +- Real-time document list with database queries +- Document statistics and usage tracking +- Search and filter capabilities +- Download and sharing options +- Company-scoped access (admin can see all company docs) + +#### Template System + +**Location**: `/dashboard/templates` (Admin only) + +**Capabilities**: + +- Create custom CV templates for company branding +- Template preview and testing functionality +- Version control and template management +- Company-wide template sharing +- Rich text editing with markdown support + +### For Candidates + +#### CV Analysis & Optimization + +**Location**: `/app` (candidate mode) + +**Analysis Process**: + +1. **Upload CV**: Submit current CV in PDF or text format +2. **Job Context**: Enter job title and job description for targeted analysis +3. **AI Analysis**: Receive comprehensive multi-dimensional scoring +4. **Detailed Feedback**: Get actionable tips and recommendations +5. **Progress Tracking**: Monitor improvements over time + +**Scoring Dimensions**: + +- **Overall Score** (0-100): Average across all categories +- **ATS Compatibility**: Keyword matching and system readability +- **Tone & Style**: Professional language and consistency +- **Content Quality**: Impact statements and quantified achievements +- **Structure & Format**: Organization and readability +- **Skills Matching**: Technical skills alignment with job requirements +- **Grammar & Formatting**: Language quality and consistency +- **Keyword Density**: Industry-specific term optimization + +**Analysis Components**: + +- `AnalyzeContent.tsx` - CV upload and job description input +- `CvDisplay.tsx` - Analysis results and scoring display +- Detailed feedback system with actionable improvement tips + +#### Analysis History & Progress + +**Location**: `/portal/analyses` + +**Features**: + +- Complete analysis history with scoring trends +- Comparison between different CV versions +- Progress tracking over time +- Export capabilities for career counseling + +#### Organization Management (Admin Candidates) + +**Location**: `/portal/organization/*` + +**Admin Capabilities**: + +- **Member Management**: Add, edit, remove organization members +- **Usage Analytics**: Track member CV analysis performance +- **Organization Analytics**: Aggregate statistics and reporting +- **Member Oversight**: View all member analyses and progress + +### Admin Features + +#### User Management + +**Location**: `/dashboard/users` (Recruiters) / `/portal/organization/members` (Candidates) + +**Functionality**: + +- Add new organization members with role assignment +- Configure document limits per user +- Monitor user activity and usage +- Role management (USER/ADMIN permissions) +- Real-time member statistics + +#### Analytics & Reporting + +**Location**: `/dashboard/analytics` / `/portal/organization/analytics` + +**Metrics Provided**: + +- **Usage Statistics**: Documents generated/analyzed per period +- **Performance Metrics**: Average scores and success rates +- **User Engagement**: Active users and retention metrics +- **System Health**: API usage and performance monitoring +- **Trend Analysis**: Historical data and growth patterns + +#### Company/Organization Management + +**Location**: `/dashboard/companies` (SUPERADMIN only) + +**System Administration**: + +- Create and configure new companies/organizations +- Set company-wide limits and permissions +- Cross-organization analytics and reporting +- System health monitoring and maintenance + +--- + +## Database & Data Models + +### Core Models + +#### User Model + +```prisma +model User { + id String @id @default(cuid()) + name String? + email String @unique + companyId String? + createdDocs Int @default(0) + allowedDocs Int @default(5) + role UserRole @default(USER) // USER, ADMIN, SUPERADMIN + userType UserType @default(RECRUITER) // RECRUITER, CANDIDATE, TESTER + isTestAccount Boolean @default(false) // Demo account flag + // Relations + company Company? @relation(fields: [companyId], references: [id]) + GeneratedDocs GeneratedDocs[] + CVAnalyses CVAnalysis[] +} ``` -For production: +#### Company Model + +```prisma +model Company { + id String @id @default(cuid()) + name String + companyType CompanyType @default(RECRUITER) // RECRUITER, CANDIDATE_ORG + allowedDocsPerUsers Int @default(5) + allowedTemplates Int @default(2) + // Relations + users User[] + GeneratedDocs GeneratedDocs[] + templates Template[] +} +``` -```zsh -npm run build -npm start +#### Generated Documents (Recruiters) + +```prisma +model GeneratedDocs { + id String @id @default(cuid()) + content String @db.Text + candidateName String + location String + rightToWork String + salaryExpectation String + notes String @db.Text + createdAt DateTime @default(now()) + // Relations + user User @relation(fields: [createdBy], references: [id]) + company Company? @relation(fields: [companyId], references: [id]) +} +``` + +#### CV Analysis (Candidates) + +```prisma +model CVAnalysis { + id String @id @default(cuid()) + fileName String + fileContent String @db.Text + jobTitle String + jobDescription String @db.Text + companyName String? + // Scoring fields + overallScore Int + atsScore Int + toneScore Int + contentScore Int + structureScore Int + skillsScore Int + grammarScore Int + keywordScore Int + // Feedback (JSON) + atsFeedback Json + toneFeedback Json + contentFeedback Json + structureFeedback Json + skillsFeedback Json + grammarFeedback Json + keywordFeedback Json + // Relations + user User @relation(fields: [userId], references: [id]) +} +``` + +### Database Operations + +#### Company-Scoped Queries + +All data access is automatically scoped to the user's company/organization: + +```typescript +// Example: Get company documents +const documents = await prisma.generatedDocs.findMany({ + where: { + companyId: user.companyId, + // Additional filters based on user role + ...(user.role === "USER" && { createdBy: user.id }), + }, +}); +``` + +#### Role-Based Access + +```typescript +// Example: Admin can see all company data, users see only their own +const canViewAllCompanyData = + user.role === "ADMIN" || user.role === "SUPERADMIN"; +const baseWhere = canViewAllCompanyData + ? { companyId: user.companyId } + : { companyId: user.companyId, userId: user.id }; ``` --- -## Core Features +## AI Integration -### CV Generation +### Google Gemini Implementation -ProfilePrep's core functionality allows recruiters to upload candidate CVs and transform them into polished, professional documents. +#### Model Configuration -#### How It Works +```typescript +const selectedModel = google("gemini-2.0-flash-001"); +``` -1. **Upload**: Upload a candidate's CV via PDF -2. **Extract**: Our system extracts the content -3. **Enhance**: AI enhances the formatting and presentation -4. **Review & Edit**: Review the generated CV and make adjustments if needed -5. **Export**: Download the polished CV or share it directly +#### CV Analysis Flow -#### Key Benefits +1. **Input Processing**: CV text and job description are sanitized and prepared +2. **Prompt Engineering**: Structured prompts with specific formatting requirements +3. **AI Analysis**: Multi-dimensional scoring across 7 categories +4. **Response Parsing**: JSON response validation and error handling +5. **Database Storage**: Structured storage of scores and feedback -- Maintains original information while improving presentation -- Ensures gender-neutral language throughout -- Fixes formatting errors without altering the CV's meaning -- Creates a consistent format across all candidate profiles +#### Analysis Prompt Structure -### CV Management +```typescript +const prepareInstructions = ({ jobTitle, jobDescription }) => ` +You are an expert in CV analysis and ATS evaluation. +Analyze the CV across these categories with scores 0-100: -The CV management system allows users to: +- ATS Compatibility: keyword matching and system readability +- Tone & Style: professional language consistency +- Content Quality: impact statements and achievements +- Structure: organization and hierarchy +- Skills: technical alignment with job requirements +- Grammar: language quality and formatting +- Keywords: industry-specific term optimization -- View all generated CVs -- Filter and search through your CV library -- Make notes on individual CVs -- View, download, or share generated profiles +Return strict JSON format with 3-4 tips per category. +`; +``` + +#### Response Format + +Each analysis returns structured feedback: + +```typescript +interface CVFeedback { + overallScore: number; + ATS: { + score: number; + tips: Array<{ + type: "good" | "improve"; + tip: string; + explanation: string; + }>; + }; + // ... additional categories +} +``` + +#### Token Usage Tracking + +```typescript +logTokenUsage(response.usage, "CV Analysis"); +``` + +### Document Generation (Recruiters) + +#### Template System Integration -### Template System +- Custom company templates stored in database +- AI applies templates during generation +- Consistent branding across all outputs +- Fallback to default template structure -For companies with established CV formats, our template system allows: +#### Content Enhancement Process -- Creation of company-specific CV templates -- Template management for different roles or departments -- Consistent branding across all candidate profiles -- Customisation options for special requirements +1. **CV Parsing**: Extract key information from uploaded CV +2. **Information Enrichment**: Add candidate details and company context +3. **Professional Formatting**: Apply consistent formatting and structure +4. **Content Optimization**: Enhance language for client presentation +5. **Template Application**: Apply company-specific branding if available -### User Management +--- + +## Development Guide + +### Adding New Features + +#### 1. Database Changes -Admin users can: +```bash +# Update Prisma schema +npm run db:format +npm run db:migrate-cr # Create migration +npm run db:migrate # Apply migration +``` + +#### 2. Server Actions -- Create and manage user accounts -- Assign appropriate permissions (User, Admin) -- Monitor user activity -- Configure user-specific settings +Create domain-specific actions in `actions/[domain].actions.ts`: -### Company Administration +```typescript +"use server"; -For multi-user organisations, the company management features offer: +import { auth } from "@/auth"; +import prisma from "@/prisma/prisma"; -- Company profile management -- User allocation and permissions within the company -- Template sharing across the organisation -- Usage reporting and analytics +export async function newFeatureAction() { + const session = await auth(); + if (!session?.user?.id) { + throw new Error("Unauthorized"); + } + + // Implement feature logic with proper company scoping + const result = await prisma.model.create({ + data: { + userId: session.user.id, + companyId: session.user.companyId, + // ... other fields + }, + }); + + return { success: true, data: result }; +} +``` + +#### 3. Client Queries + +Create TanStack Query hooks in `actions/queries/[domain].queries.ts`: + +```typescript +"use client"; + +import { newFeatureAction } from "@/actions/domain.actions"; +import { useQuery } from "@tanstack/react-query"; + +export function useNewFeature() { + return useQuery({ + queryKey: ["newFeature"], + queryFn: () => newFeatureAction(), + }); +} +``` + +#### 4. Components + +Follow the established patterns: + +- Server components for data fetching +- Client components for interactivity +- Proper error handling and loading states +- Company-scoped data access + +#### 5. Access Control + +Ensure proper role and userType checking: + +```typescript +export default async function NewFeaturePage() { + const { user } = await requireAuth("/feature"); + + // Role-based feature access + const canAccessFeature = user.role === 'ADMIN' || user.role === 'SUPERADMIN'; + + if (!canAccessFeature) { + return ; + } + + // Component logic... +} +``` + +### Code Quality Standards + +#### TypeScript + +- Use strict typing throughout +- Leverage `User` type from NextAuth +- Define proper interfaces for all API responses +- Avoid `any` types - create specific interfaces + +#### Component Architecture + +```typescript +// Server component for data fetching +export default async function ServerPage() { + const data = await fetchData(); + return ; +} + +// Client component for interactivity +"use client"; +export function ClientComponent({ data }) { + // Interactive logic here +} +``` + +#### Error Handling + +```typescript +// Consistent error handling pattern +try { + const result = await action(); + if (!result.success) { + return ; + } + return ; +} catch (error) { + return ; +} +``` + +### Testing Approach + +#### Using Demo Accounts + +1. **Role Testing**: Use role switcher to test different permission levels +2. **User Type Testing**: Switch between recruiter and candidate modes +3. **Permission Verification**: Ensure access controls work correctly +4. **Cross-Organization Testing**: Verify data isolation between companies + +#### Test Scenarios + +- Create test data with demo accounts +- Verify role-based access restrictions +- Test document limits and usage tracking +- Validate AI integration with sample CVs +- Check analytics and reporting accuracy --- -## User Roles & Permissions +## API Documentation -ProfilePrep implements a role-based access control system with three primary roles: +### Authentication Endpoints -### USER +#### NextAuth.js Integration -- Generate and manage their own CVs -- Use company templates (if part of a company) -- View their document history +```json +POST /api/auth/[...nextauth] +``` -### ADMIN +Handles all authentication flows including OAuth and demo account access. + +### CV Processing APIs + +#### CV Analysis Endpoint + +```json +POST /api/cv/analyze +Content-Type: multipart/form-data + +Body: +- fileName: string (required) +- fileContent: string (required) +- jobTitle: string (required) +- companyName: string (optional) +- jobDescription: string (optional) + +Response: +{ + "success": true, + "id": "analysis_id", + "feedback": { + "overallScore": 85, + "ATS": { "score": 90, "tips": [...] }, + // ... other categories + } +} +``` -- All USER permissions -- Create and manage users within their company -- View all company CVs and templates -- Create and edit company templates +#### User Role Management -### SUPERADMIN +```json +POST /api/user/switch-role +Content-Type: application/json -- All ADMIN permissions -- Manage companies -- Access system-wide settings -- View analytics across all companies +Body: +{ + "userType": "RECRUITER" | "CANDIDATE" +} + +Response: +{ + "success": true, + "targetEmail": "demo@profileprep.com", + "redirectUrl": "/recruiter" +} +``` + +### Server Actions + +#### CV Actions (`actions/cv.actions.ts`) + +- `createCVAnalysis()` - Process CV analysis +- `getCVAnalysis(id)` - Retrieve specific analysis +- `getUserCVAnalyses()` - Get user's analysis history + +#### User Actions (`actions/user.actions.ts`) + +- `getRecruiterDocuments()` - Fetch user documents +- `updateUserLimits()` - Modify document limits +- `switchUserRole()` - Handle role switching + +#### Admin Actions (`actions/admin.actions.ts`) + +- `getCompanyUsers()` - Manage company members +- `createCompanyUser()` - Add new organization members +- `updateUserPermissions()` - Modify user roles and limits + +### Query Hooks (`actions/queries/`) + +#### Usage Pattern + +```typescript +"use client"; +import { useCompanyUsers } from "@/actions/queries/admin.queries"; + +export function UserManagement() { + const { data: users, isLoading, error } = useCompanyUsers(); + + if (isLoading) return ; + if (error) return ; + + return ; +} +``` + +--- + +## Deployment + +### Vercel Deployment (Recommended) + +#### Setup Steps + +1. **Repository Connection**: + + - Connect GitHub repository to Vercel + - Configure automatic deployments + +2. **Environment Variables**: + + ```env + # Production environment variables + DATABASE_URL=your_production_database_url + DIRECT_URL=your_direct_database_url + GOOGLE_GENERATIVE_AI_API_KEY=your_api_key + AUTH_SECRET=your_auth_secret_32_chars + AUTH_GOOGLE_ID=your_google_oauth_id + AUTH_GOOGLE_SECRET=your_google_oauth_secret + AUTH_LINKEDIN_ID=your_linkedin_oauth_id + AUTH_LINKEDIN_SECRET=your_linkedin_oauth_secret + NODE_ENV=production + ``` + +3. **Database Configuration**: + + ```bash + # Run migrations in production + npm run db:migrate-pr + ``` + +4. **Domain Configuration**: + - Configure custom domain in Vercel dashboard + - Update OAuth redirect URLs for production domain + +#### Production Considerations + +- **Demo Account Security**: Demo accounts automatically disabled in production +- **Database Connection Pooling**: Use connection pooler for PostgreSQL +- **API Rate Limiting**: Implement rate limiting for AI API calls +- **Error Monitoring**: Set up error tracking (Sentry recommended) +- **Performance Monitoring**: Enable Vercel Analytics + +### Manual Deployment + +```bash +# Build application +npm run build + +# Set production environment +export NODE_ENV=production + +# Run database migrations +npm run db:migrate-pr + +# Start production server +npm run start +``` + +### Docker Deployment (Optional) + +```dockerfile +FROM node:18-alpine + +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production + +COPY . . +RUN npm run build + +EXPOSE 3000 +CMD ["npm", "start"] +``` + +--- + +## Testing + +### Available Scripts + +```bash +# Run all tests +npm run test + +# Watch mode for development +npm run test:watch + +# Run specific test +npm run test -- UserManagement.test.ts +``` + +### Test Structure + +```zsh +__tests__/ +├── components/ # Component tests +├── pages/ # Page integration tests +├── api/ # API endpoint tests +└── utils/ # Utility function tests +``` + +### Testing Patterns + +#### Component Testing + +```typescript +import { render, screen } from '@testing-library/react'; +import { UserList } from '@/components/admin/UserList'; + +describe('UserList', () => { + it('displays user information correctly', () => { + const mockUsers = [ + { id: '1', name: 'John Doe', email: 'john@example.com' } + ]; + + render(); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('john@example.com')).toBeInTheDocument(); + }); +}); +``` + +#### API Testing + +```typescript +import { NextRequest } from "next/server"; + +import { POST } from "@/app/api/cv/analyze/route"; + +describe("/api/cv/analyze", () => { + it("should analyze CV and return feedback", async () => { + const formData = new FormData(); + formData.append("fileName", "test-cv.pdf"); + formData.append("fileContent", "CV content here..."); + formData.append("jobTitle", "Software Engineer"); + + const request = new NextRequest("http://localhost:3000/api/cv/analyze", { + method: "POST", + body: formData, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.feedback).toBeDefined(); + }); +}); +``` + +### Demo Account Testing + +#### Functional Checks + +1. **Role Switching**: Verify role switcher works correctly +2. **Permission Testing**: Test all access control restrictions +3. **Data Isolation**: Ensure company data separation +4. **Feature Completeness**: Verify all features work for each user type +5. **Analytics Accuracy**: Validate dashboard statistics + +#### Testing Workflow + +```bash +# Start development environment +npm run dev + +# Use demo accounts to test: +# 1. Basic recruiter functionality +# 2. Admin recruiter features +# 3. Candidate analysis flow +# 4. Organization management +# 5. System admin capabilities +``` --- @@ -203,114 +1014,198 @@ ProfilePrep implements a role-based access control system with three primary rol ### Common Issues -#### The CV generation takes too long or times out +#### Database Connection Errors -**Solution:** Large or complex PDFs may take longer to process. Try breaking down the CV into smaller sections or uploading a simpler format. +**Symptoms**: `PrismaClientKnownRequestError: Can't reach database server` -#### The generated CV is missing information from the original +**Solutions**: -**Solution:** Our AI prioritises the most relevant information. If specific content is missing, add a note in the candidate information section highlighting what should be included. +1. Verify `DATABASE_URL` and `DIRECT_URL` in `.env` +2. Check PostgreSQL service is running +3. For Supabase: Verify connection pooler settings +4. Run migrations: `npm run db:migrate` +5. Check firewall/network connectivity -#### Template changes aren't reflected in generated CVs +#### Authentication Issues -**Solution:** New templates only apply to newly generated CVs. Regenerate the CV to apply the new template. +**Symptoms**: `[next-auth][error][SIGNIN_OAUTH_ERROR]` ---- +**Solutions**: -## Future Enhancements +1. Verify `AUTH_SECRET` is set and 32+ characters +2. Check OAuth provider credentials +3. Ensure redirect URLs match in OAuth app settings +4. Clear browser cookies and cache +5. Verify NextAuth.js configuration in `auth.ts` -ProfilePrep is continuously evolving to meet the needs of both recruiters and candidates. The following enhancements are proposed for development and scheduled for upcoming releases, pending confirmation. +#### AI Generation Failures -### 1. Candidate Portal +**Symptoms**: `Failed to parse CV analysis response` -The Candidate Portal is designed to empower job seekers with the same AI-powered profile optimisation tools available to recruiters. +**Solutions**: -#### Features for Candidates +1. Verify `GOOGLE_GENERATIVE_AI_API_KEY` is correct +2. Check Google AI API quota and billing +3. Review API rate limits +4. Examine raw AI response in logs +5. Validate CV content length and format -- **Comprehensive CV Management**: Candidates can upload and maintain a "master CV" containing their complete professional history -- **AI-Powered Tailoring**: Automatically customise CVs for specific job applications by analysing job descriptions -- **ATS Optimisation**: Ensure applications pass Applicant Tracking Systems by incorporating relevant keywords and formatting -- **Application Tracking**: Monitor the status of job applications within a centralised dashboard -- **Multi-Version Management**: Maintain different versions of CVs tailored to different positions or industries +#### Role/Permission Issues -#### Integration with Recruiter Workflow +**Symptoms**: Access denied or features not visible -- Companies can invite candidates to join their organisational portal -- Recruiters can review and provide feedback on candidate profiles -- Seamless application process for company job listings -- Customisable permission levels for candidate access and interactions +**Solutions**: -##### Implementation Timeline #1 +1. Check user role and userType in database +2. Verify company association +3. Clear NextAuth.js session cache +4. Ensure proper access control in page components +5. Check demo account configuration -The Candidate Portal is scheduled for release in Q1 2025 and will be available as both: +#### File Upload Problems -- A standalone subscription for individual job seekers -- An add-on feature for companies to extend to their candidates +**Symptoms**: PDF parsing fails or empty content -### 2. Job Matching +**Solutions**: -Our upcoming job matching system will streamline the recruitment process by connecting the right candidates with the right opportunities. +1. Verify PDF is not password protected +2. Check file size limits (recommended max 10MB) +3. Test with plain text files first +4. Validate PDF parsing library configuration +5. Review file upload component error handling -#### Initial Job Matching Features +### Development Issues -- **Job Listings Management**: Create, publish, and manage job postings directly within ProfilePrep -- **Application Processing**: Track and manage candidate applications through customisable status workflows -- **Basic Matching Algorithm**: Match candidates to jobs based on keyword analysis of CVs and job descriptions -- **Application Analytics**: Gain insights into application metrics and candidate sources -- **Custom Application Statuses**: Configure application tracking statuses to align with company recruitment processes +#### Build Failures -##### Implementation Timeline #2 +```bash +# Clear Next.js cache +rm -rf .next -The basic Job Matching functionality is planned for release, following the Candidate Portal, in Q2 2025. +# Clear node_modules and reinstall +rm -rf node_modules package-lock.json +npm install -### 3. Advanced Matching Algorithm +# Run type checking +npm run typecheck +``` -Following the initial job matching release, we will launch an advanced AI-powered matching system that goes beyond keyword matching to deliver truly intelligent recommendations. +#### Database Schema Issues -#### Advanced Matching Features +```bash +# Reset database (development only) +npx prisma migrate reset -- **Skills-Based Matching**: Granular matching based on explicit skills taxonomy and categorisation -- **Weighted Criteria Matching**: Sophisticated algorithm that considers multiple factors including: - - Skills alignment (technical, soft skills, certifications) - - Experience level matching (years of experience, seniority) - - Education compatibility (degree requirements, specialised training) - - Location preferences (remote, hybrid, on-site with geographic considerations) -- **Match Quality Scoring**: Percentage-based scoring system with detailed breakdown of match components -- **Two-Way Recommendations**: - - For recruiters: Find best-fit candidates for specific job openings - - For candidates: Discover most suitable job opportunities based on profile -- **Candidate Ranking**: Intelligent ranking of candidates for specific positions -- **Gap Analysis**: Identify skill or experience gaps between candidates and job requirements +# Generate Prisma client +npx prisma generate -#### Technical Implementation +# Format schema files +npm run db:format +``` -The advanced matching system will utilise: +#### Environment Configuration -- Comprehensive skills taxonomy database -- Machine learning algorithms to improve match quality over time -- Natural language processing for deep context understanding -- Customisable weighting system for different industries and roles +```bash +# Verify all required environment variables +node -e " +const required = ['DATABASE_URL', 'GOOGLE_GENERATIVE_AI_API_KEY', 'AUTH_SECRET']; +required.forEach(key => { + if (!process.env[key]) console.log('Missing:', key); +}); +" +``` + +### Production Issues + +#### Performance Problems + +1. **Database Connection Pooling**: Ensure proper connection pooling configuration +2. **API Rate Limiting**: Implement request throttling for AI APIs +3. **Query Optimization**: Review slow database queries +4. **Caching**: Implement appropriate caching strategies +5. **Bundle Analysis**: Use `@next/bundle-analyzer` to optimize bundle size + +#### Security Concerns + +1. **Demo Account Leakage**: Ensure demo accounts are disabled in production +2. **Data Isolation**: Verify company-scoped queries in all operations +3. **Input Validation**: Validate all user inputs and file uploads +4. **API Security**: Implement proper authentication for all API routes +5. **Environment Security**: Secure environment variables and secrets + +#### Monitoring & Logging + +```bash +# Enable detailed logging in production +export NEXTAUTH_DEBUG=true +export NODE_ENV=production -##### Implementation Timeline #3 +# Monitor API responses and errors +# Implement error tracking (Sentry, LogRocket, etc.) +``` + +### Getting Help + +#### Internal Resources + +1. Check existing code patterns in similar components +2. Review database schema and relationships +3. Examine server actions for proper implementation +4. Look at TanStack Query usage patterns +5. Study role-based access control implementation -The Advanced Matching Algorithm is scheduled for release in Q2 2025, following the successful deployment and adoption of the basic job matching functionality. +#### External Resources -## Contributing +1. **Next.js Documentation**: [https://nextjs.org/docs](https://nextjs.org/docs) +2. **NextAuth.js**: [https://authjs.dev/](https://authjs.dev/) +3. **Prisma**: [https://www.prisma.io/docs](https://www.prisma.io/docs) +4. **TanStack Query**: [https://tanstack.com/query/](https://tanstack.com/query/) +5. **Tailwind CSS**: [https://tailwindcss.com/docs](https://tailwindcss.com/docs) -We welcome contributions to ProfilePrep! Here's how you can help: +#### Community Support -1. **Fork the repository**: Start by forking the repository to your GitHub account -2. **Create a feature branch**: `git checkout -b feature/your-feature-name` -3. **Make your changes**: Implement your feature or fix -4. **Test thoroughly**: Ensure that the application builds and your changes work as expected -5. **Submit a pull request**: Push to your fork and submit a pull request thoroughly explaining the changes you've made +1. Next.js Discord community +2. NextAuth.js GitHub discussions +3. Prisma community forums +4. Stack Overflow with relevant tags -Please follow our coding standards and include appropriate tests with your contributions. +--- + +## Development Scripts Reference + +```bash +# Development +npm run dev # Start development server with Turbopack +npm run build # Build for production +npm run start # Start production server + +# Database Operations +npm run db:migrate # Run database migrations +npm run db:migrate-cr # Create new migration +npm run db:migrate-pr # Deploy migrations to production +npm run db:seed # Seed database with demo data +npm run db:format # Format Prisma schema files + +# Code Quality +npm run lint # Run ESLint +npm run typecheck # Run TypeScript type checking +npm run format # Format code with Prettier +npm run format:check # Check formatting without changes + +# Testing +npm run test # Run Jest tests +npm run test:watch # Run tests in watch mode + +# Utilities +npm run postinstall # Generate Prisma client (automatic after npm install) +``` --- -## License - Pending Decision +## License -ProfilePrep will be released under an open-source license. The exact license is still being decided, but it will allow for contributions and use while ensuring protection against misuse or unauthorised redistribution. +This project is proprietary software. All rights reserved. --- + +Last updated: 12 August 2025 diff --git a/actions/admin.actions.ts b/actions/admin.actions.ts index c42b31f..e369d5a 100644 --- a/actions/admin.actions.ts +++ b/actions/admin.actions.ts @@ -22,6 +22,7 @@ interface EditTemplateData { export const fetchAllUsers = async (user: { role: string; companyId?: string; + userType?: string; }) => { // Check user role if (user.role === "USER") { @@ -45,8 +46,12 @@ export const fetchAllUsers = async (user: { }); } - // If the user is a superadmin, fetch all users + // If the user is a superadmin, fetch all users (or filter by user type if specified) + const whereClause = user.userType + ? { userType: user.userType as "RECRUITER" | "CANDIDATE" | "TESTER" } + : {}; return await prisma.user.findMany({ + where: whereClause, include: { company: true, }, @@ -196,7 +201,9 @@ export const deleteUser = async (userId: string, sessionUser: User) => { } }; -export const createCompany = async (companyData: NewCompanyData) => { +export const createCompany = async ( + companyData: NewCompanyData & { companyType?: string }, +) => { try { const company = await prisma.company.create({ data: { @@ -204,6 +211,9 @@ export const createCompany = async (companyData: NewCompanyData) => { allowedDocsPerUsers: companyData.allowedDocsPerUsers, allowedTemplates: companyData.allowedTemplates, createdTemplates: 0, + companyType: + (companyData.companyType as "RECRUITER" | "CANDIDATE_ORG") || + "RECRUITER", }, }); return company; @@ -214,7 +224,7 @@ export const createCompany = async (companyData: NewCompanyData) => { export const updateCompany = async ( companyId: string, - companyData: NewCompanyData, + companyData: NewCompanyData & { companyType?: string }, sessionUser: User, ) => { // Check permissions @@ -259,6 +269,9 @@ export const updateCompany = async ( name: companyData.name, allowedDocsPerUsers: companyData.allowedDocsPerUsers, allowedTemplates: companyData.allowedTemplates, + ...(companyData.companyType && { + companyType: companyData.companyType as "RECRUITER" | "CANDIDATE_ORG", + }), }, }); @@ -331,14 +344,22 @@ export const deleteCompany = async (companyId: string, sessionUser: User) => { } }; -export const fetchAllCompanies = async (sessionUser: User) => { +export const fetchAllCompanies = async ( + sessionUser: User, + companyType?: string, +) => { if (sessionUser.role !== "SUPERADMIN") { throw new Error( "403 Forbidden: You do not have permission to access this resource.", ); } + const whereClause = companyType + ? { companyType: companyType as "RECRUITER" | "CANDIDATE_ORG" } + : {}; + return await prisma.company.findMany({ + where: whereClause, include: { _count: { select: { @@ -718,3 +739,123 @@ export const deleteTemplate = async (templateId: string, sessionUser: User) => { return handleError(error); } }; + +// Candidate organization actions +export const getOrganizationMembers = async (user: User) => { + if ( + (user.role !== "ADMIN" && user.role !== "SUPERADMIN") || + user.userType !== "CANDIDATE" + ) { + throw new Error( + "Unauthorized: Insufficient permissions for candidate organization access", + ); + } + + if (!user.company?.id) { + throw new Error("User not associated with any organization"); + } + + return await prisma.user.findMany({ + where: { + companyId: user.company.id, + userType: "CANDIDATE", + }, + select: { + id: true, + name: true, + email: true, + createdDocs: true, + allowedDocs: true, + createdAt: true, + role: true, + isTestAccount: true, + }, + orderBy: { + createdAt: "desc", + }, + }); +}; + +export const getOrganizationAnalyses = async (user: User) => { + if ( + (user.role !== "ADMIN" && user.role !== "SUPERADMIN") || + user.userType !== "CANDIDATE" + ) { + throw new Error( + "Unauthorized: Insufficient permissions for candidate organization access", + ); + } + + if (!user.company?.id) { + return []; + } + + return await prisma.cVAnalysis.findMany({ + where: { + user: { + companyId: user.company.id, + userType: "CANDIDATE", + }, + }, + include: { + user: { + select: { + name: true, + email: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); +}; + +export const getOrganizationAnalytics = async (user: User) => { + if ( + (user.role !== "ADMIN" && user.role !== "SUPERADMIN") || + user.userType !== "CANDIDATE" + ) { + throw new Error( + "Unauthorized: Insufficient permissions for candidate organization access", + ); + } + + const [orgMembers, orgAnalyses] = await Promise.all([ + getOrganizationMembers(user), + getOrganizationAnalyses(user), + ]); + + const totalMembers = orgMembers.length; + const totalAnalyses = orgAnalyses.length; + const avgScore = + orgAnalyses.length > 0 + ? Math.round( + orgAnalyses.reduce((sum, a) => sum + a.overallScore, 0) / + orgAnalyses.length, + ) + : 0; + const totalUsed = orgMembers.reduce( + (sum, member) => sum + member.createdDocs, + 0, + ); + const totalAllowed = orgMembers.reduce( + (sum, member) => sum + member.allowedDocs, + 0, + ); + const usageRate = + totalAllowed > 0 ? Math.round((totalUsed / totalAllowed) * 100) : 0; + + return { + members: orgMembers, + analyses: orgAnalyses, + stats: { + totalMembers, + totalAnalyses, + avgScore, + totalUsed, + totalAllowed, + usageRate, + }, + }; +}; diff --git a/actions/cv.actions.ts b/actions/cv.actions.ts new file mode 100644 index 0000000..7400767 --- /dev/null +++ b/actions/cv.actions.ts @@ -0,0 +1,345 @@ +"use server"; + +import { auth } from "@/auth"; +import prisma from "@/prisma/prisma"; +import { google } from "@ai-sdk/google"; +import { generateText } from "ai"; + +import { logTokenUsage } from "@/lib/utils"; + +const selectedModel = google("gemini-2.0-flash-001"); + +interface CVFeedback { + overallScore: number; + ATS: { + score: number; + tips: Array<{ + type: "good" | "improve"; + tip: string; + explanation?: string; + }>; + }; + toneAndStyle: { + score: number; + tips: Array<{ + type: "good" | "improve"; + tip: string; + explanation: string; + }>; + }; + content: { + score: number; + tips: Array<{ + type: "good" | "improve"; + tip: string; + explanation: string; + }>; + }; + structure: { + score: number; + tips: Array<{ + type: "good" | "improve"; + tip: string; + explanation: string; + }>; + }; + skills: { + score: number; + tips: Array<{ + type: "good" | "improve"; + tip: string; + explanation: string; + }>; + }; + grammarAndFormatting: { + score: number; + tips: Array<{ + type: "good" | "improve"; + tip: string; + explanation: string; + }>; + }; + keywordDensity: { + score: number; + tips: Array<{ + type: "good" | "improve"; + tip: string; + explanation: string; + }>; + }; +} + +const AIResponseFormat = ` +interface Feedback { + overallScore: number; // average of all sections, max 100 + + ATS: { + score: number; // rate based on ATS suitability - evaluate keyword match, relevance to job, and system readability + tips: { + type: "good" | "improve"; + tip: string; // short "title" - e.g. "Keyword-rich bullet points" - provide 3-4 tips + explanation?: string; // explain in detail here, e.g. "Used role-specific terms like 'Node.js', 'REST API'" + }[]; // exactly 3-4 tips required + }; + + toneAndStyle: { + score: number; // max 100 + tips: { + type: "good" | "improve"; + tip: string; // short "title" - e.g. "Professional tone maintained" + explanation: string; // explain in detail here, e.g. "Consistent use of action verbs and confident language throughout the CV" + }[]; // exactly 3-4 tips required + }; + + content: { + score: number; // max 100 + tips: { + type: "good" | "improve"; + tip: string; // short "title" - e.g. "Quantified impact statements" + explanation: string; // explain in detail here, e.g. "Used measurable results like 'increased conversion rate by 23%' to demonstrate impact" + }[]; // exactly 3-4 tips required + }; + + structure: { + score: number; // max 100 + tips: { + type: "good" | "improve"; + tip: string; // short "title" - e.g. "Clear section hierarchy" + explanation: string; // explain in detail here, e.g. "Experience, Skills, and Education are organised in a logical and expected order" + }[]; // exactly 3-4 tips required + }; + + skills: { + score: number; // max 100 + tips: { + type: "good" | "improve"; + tip: string; // short "title" - e.g. "Relevant tech stack highlighted" + explanation: string; // explain in detail here, e.g. "Frontend skills like React and Tailwind match the job requirements" + }[]; // exactly 3-4 tips required + }; + + grammarAndFormatting: { + score: number; // max 100 + tips: { + type: "good" | "improve"; + tip: string; // short "title" - e.g. "Consistent punctuation" + explanation: string; // explain in detail here, e.g. "Used full stops consistently in all bullet points" + }[]; // exactly 3-4 tips required + }; + + keywordDensity: { + score: number; // max 100 + tips: { + type: "good" | "improve"; + tip: string; // short "title" - e.g. "Role-specific keywords present" + explanation: string; // explain in detail here, e.g. "Included frequent mentions of 'Agile', 'JavaScript', and 'API development'" + }[]; // exactly 3-4 tips required + }; +}`; + +const prepareInstructions = ({ + jobTitle, + jobDescription, +}: { + jobTitle: string; + jobDescription: string; +}) => ` +You are an expert in CV analysis and ATS (Applicant Tracking System) evaluation. + +Please analyse the candidate's CV and rate it across each category. Use the job title and job description (if available) to assess alignment. + +Use the following guidance when assigning scores: + +- 90-100: Excellent. Fully aligned with best practices. No major improvements needed. +- 70-89: Strong. Some minor issues but generally solid. +- 50-69: Average. Noticeable room for improvement. +- 30-49: Weak. Several important issues. +- 0-29: Poor. Major improvements required throughout. + +Do not hesitate to give low scores if the CV has significant flaws. This feedback is meant to help the candidate improve. + +Job Title: ${jobTitle} +Job Description: ${jobDescription} + +Return your response using the following strict JSON format: +${AIResponseFormat} + +For every category, provide 3-4 tips only. +Each tip must be a short "title" summarising the point. +Each tip must have an explanation where you explain in detail. + +Do not include backticks or markdown syntax. +Do not return any additional text, explanation, or commentary outside the JSON. +`; + +export async function createCVAnalysis( + fileName: string, + fileContent: string, + jobTitle: string, + companyName: string = "", + jobDescription: string = "", +) { + const session = await auth(); + + if (!session?.user?.id) { + throw new Error("Unauthorized: No user session found"); + } + + const instructions = prepareInstructions({ + jobTitle, + jobDescription, + }); + + const prompt = `${instructions} + +CV CONTENT: +${fileContent} + +Please analyze this CV and provide feedback in the specified JSON format.`; + + try { + const response = await generateText({ + model: selectedModel, + prompt, + temperature: 0.2, + }); + + let cleanedText = response.text; + + // Remove any markdown code blocks if present + if (cleanedText.includes("```")) { + cleanedText = cleanedText.replace(/```json\n?|```\n?/g, ""); + } + + // Clean up common JSON issues - remove control characters + cleanedText = cleanedText + .trim() + .replace(/[\u0000-\u001F\u007F-\u009F]/g, " "); // Replace control characters with space + + let feedback: CVFeedback; + try { + feedback = JSON.parse(cleanedText); + } catch (parseError) { + console.error("Failed to parse AI response:", parseError); + console.error("Raw response:", cleanedText.substring(0, 500)); + throw new Error( + "Failed to parse CV analysis response. Please try again.", + ); + } + + logTokenUsage(response.usage, "CV Analysis"); + + // Save to database + const cvAnalysis = await prisma.cVAnalysis.create({ + data: { + userId: session.user.id, + fileName, + fileContent, + companyName, + jobTitle, + jobDescription, + overallScore: feedback.overallScore, + atsScore: feedback.ATS.score, + atsFeedback: feedback.ATS, + toneScore: feedback.toneAndStyle.score, + toneFeedback: feedback.toneAndStyle, + contentScore: feedback.content.score, + contentFeedback: feedback.content, + structureScore: feedback.structure.score, + structureFeedback: feedback.structure, + skillsScore: feedback.skills.score, + skillsFeedback: feedback.skills, + grammarScore: feedback.grammarAndFormatting.score, + grammarFeedback: feedback.grammarAndFormatting, + keywordScore: feedback.keywordDensity.score, + keywordFeedback: feedback.keywordDensity, + }, + }); + + return { + success: true, + analysis: cvAnalysis, + feedback, + }; + } catch (error) { + console.error("CV Analysis failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Analysis failed", + }; + } +} + +export async function getCVAnalysis(id: string) { + const session = await auth(); + + if (!session?.user?.id) { + throw new Error("Unauthorized: No user session found"); + } + + try { + const analysis = await prisma.cVAnalysis.findFirst({ + where: { + id, + userId: session.user.id, + }, + }); + + if (!analysis) { + return { + success: false, + error: "Analysis not found", + }; + } + + return { + success: true, + analysis, + }; + } catch (error) { + console.error("Failed to fetch CV analysis:", error); + return { + success: false, + error: "Failed to fetch analysis", + }; + } +} + +export async function getUserCVAnalyses() { + const session = await auth(); + + if (!session?.user?.id) { + throw new Error("Unauthorized: No user session found"); + } + + try { + const analyses = await prisma.cVAnalysis.findMany({ + where: { + userId: session.user.id, + }, + orderBy: { + createdAt: "desc", + }, + select: { + id: true, + fileName: true, + jobTitle: true, + companyName: true, + overallScore: true, + atsScore: true, + createdAt: true, + }, + }); + + return { + success: true, + analyses, + }; + } catch (error) { + console.error("Failed to fetch user CV analyses:", error); + return { + success: false, + error: "Failed to fetch analyses", + }; + } +} diff --git a/actions/queries/admin.queries.ts b/actions/queries/admin.queries.ts index 501171c..8776545 100644 --- a/actions/queries/admin.queries.ts +++ b/actions/queries/admin.queries.ts @@ -1,7 +1,13 @@ "use client"; -import { fetchAllUsers, getAllUserDocs } from "@/actions/admin.actions"; -import { fetchAllCompanies } from "@/actions/admin.actions"; +import { + fetchAllCompanies, + fetchAllUsers, + getAllUserDocs, + getOrganizationAnalyses, + getOrganizationAnalytics, + getOrganizationMembers, +} from "@/actions/admin.actions"; import { useQuery } from "@tanstack/react-query"; import { User } from "next-auth"; @@ -26,3 +32,43 @@ export const useAdminDocsQuery = (user: User) => { queryFn: () => getAllUserDocs(user), }); }; + +// Candidate organization queries +export const useOrganizationMembersQuery = (user: User) => { + return useQuery({ + queryKey: ["organizationMembers", user.company?.id], + queryFn: () => getOrganizationMembers(user), + enabled: !!( + user.company?.id && + user.userType === "CANDIDATE" && + (user.role === "ADMIN" || user.role === "SUPERADMIN") + ), + staleTime: 1000 * 60 * 5, // 5 minutes + }); +}; + +export const useOrganizationAnalysesQuery = (user: User) => { + return useQuery({ + queryKey: ["organizationAnalyses", user.company?.id], + queryFn: () => getOrganizationAnalyses(user), + enabled: !!( + user.company?.id && + user.userType === "CANDIDATE" && + (user.role === "ADMIN" || user.role === "SUPERADMIN") + ), + staleTime: 1000 * 60 * 5, // 5 minutes + }); +}; + +export const useOrganizationAnalyticsQuery = (user: User) => { + return useQuery({ + queryKey: ["organizationAnalytics", user.company?.id], + queryFn: () => getOrganizationAnalytics(user), + enabled: !!( + user.company?.id && + user.userType === "CANDIDATE" && + (user.role === "ADMIN" || user.role === "SUPERADMIN") + ), + staleTime: 1000 * 60 * 2, // 2 minutes for analytics + }); +}; diff --git a/actions/queries/cv.queries.ts b/actions/queries/cv.queries.ts new file mode 100644 index 0000000..05427df --- /dev/null +++ b/actions/queries/cv.queries.ts @@ -0,0 +1,21 @@ +"use client"; + +import { getCVAnalysis, getUserCVAnalyses } from "@/actions/cv.actions"; +import { useQuery } from "@tanstack/react-query"; + +export const useCVAnalysisQuery = (id: string) => { + return useQuery({ + queryKey: ["cvAnalysis", id], + queryFn: () => getCVAnalysis(id), + enabled: !!id, + staleTime: 1000 * 60 * 10, // 10 minutes + }); +}; + +export const useUserCVAnalysesQuery = () => { + return useQuery({ + queryKey: ["userCVAnalyses"], + queryFn: getUserCVAnalyses, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +}; diff --git a/actions/queries/user.queries.ts b/actions/queries/user.queries.ts index 685fbc2..68c555f 100644 --- a/actions/queries/user.queries.ts +++ b/actions/queries/user.queries.ts @@ -1,7 +1,11 @@ "use client"; import { fetchAllTemplates } from "@/actions/admin.actions"; -import { getDocContent, getUserDocs } from "@/actions/user.actions"; +import { + getDocContent, + getRecruiterDocuments, + getUserDocs, +} from "@/actions/user.actions"; import { useQuery } from "@tanstack/react-query"; import { User } from "next-auth"; @@ -29,3 +33,12 @@ export const useTemplatesQuery = (user: User) => { enabled: !!user.company, }); }; + +export const useRecruiterDocumentsQuery = (userId: string) => { + return useQuery({ + queryKey: ["recruiterDocuments", userId], + queryFn: () => getRecruiterDocuments(userId), + enabled: !!userId, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +}; diff --git a/actions/stats.actions.ts b/actions/stats.actions.ts index 2a9563b..8657de8 100644 --- a/actions/stats.actions.ts +++ b/actions/stats.actions.ts @@ -11,10 +11,11 @@ export async function getTotalUsers(user: User) { return await prisma.user.count(); } - // count only users in their company (admins) + // count only users in their company with the same user type (admins) return await prisma.user.count({ where: { companyId: user.company?.id, + userType: user.userType, // Only count users of the same type (RECRUITER/CANDIDATE) }, }); } @@ -25,10 +26,13 @@ export async function getTotalDocs(user: User) { return await prisma.generatedDocs.count(); } - // count only docs from their company (admins) + // count only docs from their company for same user type (admins) return await prisma.generatedDocs.count({ where: { companyId: user.company?.id, + user: { + userType: user.userType, + }, }, }); } @@ -44,10 +48,15 @@ export async function getDocsWithTrend(user: User, userId?: string) { // company filter based on user role const companyFilter = getCompanyFilter(user); - // combine filters + // combine filters with user type filter for admins const whereFilter = { ...userFilter, ...companyFilter, + ...(user.role === "ADMIN" && { + user: { + userType: user.userType, + }, + }), createdAt: { gte: thirtyDaysAgo, }, @@ -65,6 +74,11 @@ export async function getDocsWithTrend(user: User, userId?: string) { where: { ...userFilter, ...companyFilter, + ...(user.role === "ADMIN" && { + user: { + userType: user.userType, + }, + }), createdAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo, @@ -104,7 +118,7 @@ export async function getTotalCompanies(user: User) { return await prisma.company.count(); } - // return their own company count (admins) + // return their own company count (admins) - always 1 for admins return 1; } @@ -119,6 +133,11 @@ export async function getRecentActivity(user: User, userId?: string) { where: { ...userFilter, ...companyFilter, + ...(user.role === "ADMIN" && { + user: { + userType: user.userType, + }, + }), }, take: 5, orderBy: { @@ -186,10 +205,11 @@ export async function getSystemStats(user: User) { }); totalAllowedDocs = allowedDocsAgg._sum.allowedDocs || 1; } else { - // sum of allowed docs for users in this company (admins) + // sum of allowed docs for users in this company with same user type (admins) const allowedDocsAgg = await prisma.user.aggregate({ where: { companyId: user.company?.id, + userType: user.userType, }, _sum: { allowedDocs: true, @@ -230,3 +250,116 @@ export async function getUserStats(userId?: string) { recentDocs, }; } + +// Candidate-specific statistics functions +export async function getCandidateStats(user: User) { + if (user.userType !== "CANDIDATE") { + return { + totalAnalyses: 0, + averageScore: 0, + bestScore: 0, + recentAnalyses: 0, + }; + } + + // Base filter for the user's analyses + const userFilter = { userId: user.id }; + + // Company filter for admin candidates + let companyFilter = {}; + if (user.role === "ADMIN" && user.company?.id) { + companyFilter = { + user: { + companyId: user.company.id, + userType: "CANDIDATE", + }, + }; + } + + const totalAnalyses = await prisma.cVAnalysis.count({ + where: user.role === "ADMIN" ? companyFilter : userFilter, + }); + + // Get average score for the candidate or organization + const analysesWithScores = await prisma.cVAnalysis.findMany({ + where: user.role === "ADMIN" ? companyFilter : userFilter, + select: { + overallScore: true, + }, + }); + + const averageScore = + analysesWithScores.length > 0 + ? Math.round( + analysesWithScores.reduce( + (sum, analysis) => sum + analysis.overallScore, + 0, + ) / analysesWithScores.length, + ) + : 0; + + const bestScore = + analysesWithScores.length > 0 + ? Math.max(...analysesWithScores.map((a) => a.overallScore)) + : 0; + + // Recent analyses (last 30 days) + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const recentAnalyses = await prisma.cVAnalysis.count({ + where: { + ...(user.role === "ADMIN" ? companyFilter : userFilter), + createdAt: { + gte: thirtyDaysAgo, + }, + }, + }); + + return { + totalAnalyses, + averageScore, + bestScore, + recentAnalyses, + }; +} + +export async function getCandidateUserStats(userId: string) { + if (!userId) { + return { + totalAnalyses: 0, + bestScore: 0, + averageScore: 0, + }; + } + + const totalAnalyses = await prisma.cVAnalysis.count({ + where: { userId }, + }); + + const analysesWithScores = await prisma.cVAnalysis.findMany({ + where: { userId }, + select: { overallScore: true }, + }); + + const bestScore = + analysesWithScores.length > 0 + ? Math.max(...analysesWithScores.map((a) => a.overallScore)) + : 0; + + const averageScore = + analysesWithScores.length > 0 + ? Math.round( + analysesWithScores.reduce( + (sum, analysis) => sum + analysis.overallScore, + 0, + ) / analysesWithScores.length, + ) + : 0; + + return { + totalAnalyses, + bestScore, + averageScore, + }; +} diff --git a/actions/user.actions.ts b/actions/user.actions.ts index 9275e96..28b6ddc 100644 --- a/actions/user.actions.ts +++ b/actions/user.actions.ts @@ -71,6 +71,31 @@ export async function getUserDocs(userId: string) { } } +export async function getRecruiterDocuments(userId: string) { + try { + const documents = await prisma.generatedDocs.findMany({ + where: { + createdBy: userId, + }, + include: { + company: { + select: { + name: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return { success: true, documents }; + } catch (error) { + console.error("Failed to fetch recruiter documents:", error); + return { success: false, error: "Failed to fetch documents" }; + } +} + export async function deleteDoc(docId: string) { try { await prisma.generatedDocs.delete({ @@ -139,3 +164,40 @@ export async function createGeneratedDoc({ return { success: false, error: "Failed to create document" }; } } + +export async function createTestAccount(email: string) { + try { + const testUser = await prisma.user.create({ + data: { + email, + name: "Test Account", + userType: "TESTER", + isTestAccount: true, + role: "USER", + allowedDocs: 999, + }, + }); + + return { success: true, user: testUser }; + } catch (error) { + console.error("Failed to create test account:", error); + return { success: false, error: "Failed to create test account" }; + } +} + +export async function updateUserType( + userId: string, + userType: "RECRUITER" | "CANDIDATE" | "TESTER", +) { + try { + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: { userType }, + }); + + return { success: true, user: updatedUser }; + } catch (error) { + console.error("Failed to update user type:", error); + return { success: false, error: "Failed to update user type" }; + } +} diff --git a/app/api/cv/analyze/route.ts b/app/api/cv/analyze/route.ts new file mode 100644 index 0000000..0aa305b --- /dev/null +++ b/app/api/cv/analyze/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { createCVAnalysis } from "@/actions/cv.actions"; +import { auth } from "@/auth"; + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const fileName = formData.get("fileName") as string; + const fileContent = formData.get("fileContent") as string; + const jobTitle = formData.get("jobTitle") as string; + const companyName = (formData.get("companyName") as string) || ""; + const jobDescription = (formData.get("jobDescription") as string) || ""; + + if (!fileName || !fileContent || !jobTitle) { + return NextResponse.json( + { + error: "Missing required fields: fileName, fileContent, and jobTitle", + }, + { status: 400 }, + ); + } + + // Validate that we have actual content + if (fileContent.trim().length < 100) { + return NextResponse.json( + { error: "CV content appears to be too short or empty" }, + { status: 400 }, + ); + } + + // Create CV analysis + const result = await createCVAnalysis( + fileName, + fileContent, + jobTitle, + companyName, + jobDescription, + ); + + if (!result.success) { + return NextResponse.json( + { error: result.error || "Failed to analyze CV" }, + { status: 500 }, + ); + } + + return NextResponse.json({ + success: true, + id: result.analysis?.id, + feedback: result.feedback, + }); + } catch (error) { + console.error("CV analysis API error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/user/switch-role/route.ts b/app/api/user/switch-role/route.ts new file mode 100644 index 0000000..5e74869 --- /dev/null +++ b/app/api/user/switch-role/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import prisma from "@/prisma/prisma"; + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Only allow test accounts to switch roles + if (!session.user.isTestAccount) { + return NextResponse.json( + { error: "Role switching is only available for test accounts" }, + { status: 403 }, + ); + } + + const { userType } = await request.json(); + + if (!userType || !["RECRUITER", "CANDIDATE"].includes(userType)) { + return NextResponse.json({ error: "Invalid user type" }, { status: 400 }); + } + + // Get the appropriate demo account based on requested user type + const demoAccountEmails = { + RECRUITER: "demo@profileprep.com", + CANDIDATE: "candidate.demo@profileprep.com", + }; + + const targetEmail = + demoAccountEmails[userType as keyof typeof demoAccountEmails]; + + const targetUser = await prisma.user.findUnique({ + where: { email: targetEmail }, + include: { + company: true, + }, + }); + + if (!targetUser) { + return NextResponse.json( + { error: "Target demo account not found" }, + { status: 404 }, + ); + } + + return NextResponse.json({ + success: true, + userType, + targetEmail, + redirectUrl: userType === "CANDIDATE" ? "/portal" : "/recruiter", + message: `Ready to switch to ${userType.toLowerCase()} mode`, + }); + } catch (error) { + console.error("Role switch API error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/user/update-type/route.ts b/app/api/user/update-type/route.ts new file mode 100644 index 0000000..04e7010 --- /dev/null +++ b/app/api/user/update-type/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import prisma from "@/prisma/prisma"; + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { userType } = await request.json(); + + if (!userType || !["RECRUITER", "CANDIDATE", "TESTER"].includes(userType)) { + return NextResponse.json({ error: "Invalid user type" }, { status: 400 }); + } + + // Update user type in database + await prisma.user.update({ + where: { id: session.user.id }, + data: { userType }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Update user type error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/app/app/_components/AnalyzeContent.tsx b/app/app/_components/AnalyzeContent.tsx new file mode 100644 index 0000000..7ad27a8 --- /dev/null +++ b/app/app/_components/AnalyzeContent.tsx @@ -0,0 +1,404 @@ +"use client"; + +import type React from "react"; +import { useState } from "react"; + +import { useRouter } from "next/navigation"; + +import { + BarChart3, + FileTextIcon, + FileUpIcon, + Target, + TrendingUp, + XIcon, +} from "lucide-react"; +import { useSession } from "next-auth/react"; +import { useDropzone } from "react-dropzone"; +import { useErrorBoundary } from "react-error-boundary"; +import { toast } from "react-hot-toast"; + +import { BackButton, NextButton } from "@/components/global/NavigationButtons"; +import { Spinner } from "@/components/global/Spinner"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +import { extractTextFromPdf } from "@/lib/utils"; + +interface AnalysisData { + jobTitle: string; + companyName: string; + jobDescription: string; +} + +function AnalyzeContent() { + const { showBoundary } = useErrorBoundary(); + const { data: session } = useSession(); + const router = useRouter(); + + const [selectedFile, setSelectedFile] = useState(null); + const [extractedText, setExtractedText] = useState(""); + const [isExtracting, setIsExtracting] = useState(false); + const [showJobDetails, setShowJobDetails] = useState(false); + const [showJobDescription, setShowJobDescription] = useState(false); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [analysisProgress, setAnalysisProgress] = useState(0); + const [analysisResult, setAnalysisResult] = useState(null); + const [extractError, setExtractError] = useState(null); + + const [analysisData, setAnalysisData] = useState({ + jobTitle: "", + companyName: "", + jobDescription: "", + }); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept: { + "application/pdf": [".pdf"], + }, + maxFiles: 1, + onDrop: async (acceptedFiles) => { + if (acceptedFiles.length > 0) { + const file = acceptedFiles[0]; + setSelectedFile(file); + setExtractError(null); + + // Extract text from PDF client-side + await extractTextFromPdf( + file, + setIsExtracting, + setExtractedText, + setExtractError, + ); + } + }, + }); + + const handleRemoveFile = () => { + setSelectedFile(null); + setExtractedText(""); + setExtractError(null); + setShowJobDetails(false); + setShowJobDescription(false); + }; + + const handleNext = () => { + if (!showJobDetails) { + if (!selectedFile) { + toast.error("Please select a CV file"); + return; + } + if (!extractedText) { + toast.error( + "Failed to extract text from PDF. Please try another file.", + ); + return; + } + setShowJobDetails(true); + } else if (!showJobDescription) { + if (!analysisData.jobTitle) { + toast.error("Please enter a job title"); + return; + } + setShowJobDescription(true); + } else { + handleAnalyze(); + } + }; + + const handleBack = () => { + if (analysisResult) { + setAnalysisResult(null); + } else if (showJobDescription) { + setShowJobDescription(false); + } else if (showJobDetails) { + setShowJobDetails(false); + } + }; + + const handleInputChange = ( + e: React.ChangeEvent, + ) => { + const { id, value } = e.target; + setAnalysisData((prev) => ({ ...prev, [id]: value })); + }; + + const handleAnalyze = async () => { + if (!selectedFile || !extractedText || !analysisData.jobTitle) { + toast.error("Please provide all required information"); + return; + } + + if (!session?.user) { + toast.error("Please log in to analyze your CV"); + return; + } + + setIsAnalyzing(true); + setAnalysisProgress(0); + + // Progress simulation + const duration = 8000; // 8 seconds + const interval = 50; + const steps = duration / interval; + let currentStep = 0; + + const progressInterval = setInterval(() => { + currentStep++; + const progress = Math.min(99, Math.round((currentStep / steps) * 99)); + setAnalysisProgress(progress); + }, interval); + + try { + const formData = new FormData(); + formData.append("fileName", selectedFile.name); + formData.append("fileContent", extractedText); + formData.append("jobTitle", analysisData.jobTitle); + formData.append("companyName", analysisData.companyName); + formData.append("jobDescription", analysisData.jobDescription); + + const response = await fetch("/api/cv/analyze", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error("Failed to analyze CV"); + } + + const result = await response.json(); + + clearInterval(progressInterval); + setAnalysisProgress(100); + + toast.success("CV analyzed successfully!"); + + // Redirect to analysis results page + router.push(`/portal/analysis/${result.id}`); + } catch (error) { + clearInterval(progressInterval); + console.error("Analysis failed:", error); + toast.error(error instanceof Error ? error.message : "Analysis failed"); + showBoundary(error); + } finally { + clearInterval(progressInterval); + setIsAnalyzing(false); + } + }; + + if (isAnalyzing) { + return ( +
+ + + Analyzing CV... {Math.round(analysisProgress)}% Complete + +
+ ); + } + + return ( +
+
+ {!showJobDetails ? ( + // Step 1: File Upload + + + Upload Your CV for Analysis + + Get AI-powered feedback and ATS compatibility scoring for your + resume + + + + {!selectedFile ? ( +
+ + + {isDragActive ? ( +

+ Drop your CV here... +

+ ) : ( +
+

+ Click to upload or drag and drop +

+

+ PDF files only, up to 10MB +

+
+ )} +
+ ) : ( +
+
+ +
+

{selectedFile.name}

+

+ {(selectedFile.size / 1024 / 1024).toFixed(2)} MB +

+ {isExtracting && ( +

+ Extracting text... +

+ )} + {extractedText && !isExtracting && ( +

+ ✓ Text extracted successfully +

+ )} + {extractError && ( +

+ ✗ Failed to extract text +

+ )} +
+ +
+ +
+
+ +

ATS Score

+
+
+ +

Job Match

+
+
+ +

+ Improvements +

+
+
+
+ )} + + {selectedFile && ( +
+ +
+ )} +
+
+ ) : !showJobDescription ? ( + // Step 2: Job Details + + + Target Job Information + + Provide job details for tailored CV analysis and recommendations + + + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ ) : ( + // Step 3: Job Description + + + Job Description + + Paste the job description for more accurate analysis and keyword + matching + + + +
+ +