diff --git a/.gitignore b/.gitignore index 181298b..72def86 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ auth-proxy/node_modules # misc .vscode +.cursor +.claude .DS_Store *.pem diff --git a/README.md b/README.md index 7d778d9..37de2d2 100644 --- a/README.md +++ b/README.md @@ -1,201 +1,1124 @@ -# 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) +- [Onboarding Data Mapping](#onboarding-data-mapping) +- [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 ``` -3. Set up environment variables: + 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 + ``` + +5. **Access Demo Accounts** (Development Only): + - Visit `http://localhost:3000/login` + - Use demo accounts provided below + +### Demo Account System - - Duplicate `.env.local.example` to create a `.env.local` file and insert your credentials. +**⚠️ IMPORTANT: Demo accounts are ONLY available in development mode (`NODE_ENV=development`)** -### Database Configuration +#### Available Demo Accounts -Initialise the database with Prisma: +**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:** + +- **`superadmin.demo@profileprep.com`** / `SuperAdmin2024!` - System-wide access + +#### Role Switching System + +- **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 + +--- + +## Onboarding Data Mapping + +### User Onboarding Flow + +ProfilePrep implements a comprehensive 10-step onboarding process that collects user preferences and stores them in the database. Each step maps to specific database fields: + +### Step 1: User Type Selection (OnboardingBackground component) + +- **Question**: "I'm a Recruiter" vs "I'm a Job Seeker" +- **Saved to**: `userType` (UserType enum: RECRUITER | CANDIDATE | TESTER) + +### Step 2: Field Selection (EnhancedOnboarding - FIELD_SELECTION) + +- **Question**: "Select Your Field" (Tech, Healthcare, Finance, etc.) +- **Saved to**: `field` (String) + +### Step 3: Specialization Selection (EnhancedOnboarding - SPECIALIZATION) + +- **Question**: "Select Specializations" (multiple checkboxes) +- **Saved to**: `specializations` (String[]) + +### Step 4: Goals & Experience (EnhancedOnboarding - GOALS_EXPERIENCE) + +All saved to `guidancePreferences` (Json object) containing: + +- **Experience Level**: `guidancePreferences.experienceLevel` (entry|mid|senior|executive|changing) + - Also mapped to: `careerStage` (entry_level|mid_level|senior_level|executive|career_change) +- **Primary Goals**: `guidancePreferences.primaryGoals` (String[]) +- **Job Search Status** (Candidates only): `guidancePreferences.jobSearchStatus` (active|passive|not_looking|starting_soon) + +### Step 5: Learning Preferences (EnhancedOnboarding - LEARNING_PREFERENCES) + +- **Learning Style**: `guidancePreferences.learningStyle` (visual|reading|interactive|video|mixed) +- **Time Commitment**: `guidancePreferences.timeCommitment` (Number - minutes per week) +- **Pace Preference**: `guidancePreferences.pacePreference` (self_paced|structured|accelerated) + +### Step 6: Topic Priorities & Challenges (EnhancedOnboarding - TOPIC_PRIORITIES) + +- **Current Challenges**: `guidancePreferences.currentChallenges` (String[]) +- **Priority Topics**: `guidancePreferences.priorityTopics` (String[]) +- **Urgent Needs**: `guidancePreferences.urgentNeeds` (String[]) + +### Step 7: Personalization & Preferences (EnhancedOnboarding - PERSONALIZATION) + +- **Reminders**: `guidancePreferences.reminders` (Boolean) +- **Progress Sharing**: `guidancePreferences.progressSharing` (Boolean) +- **Mentorship Interest**: `guidancePreferences.mentorshipInterest` (Boolean) +- **Specific Challenges**: `guidancePreferences.specificChallenges` (String - optional) +- **Additional Info**: `guidancePreferences.additionalInfo` (String - optional) + +### Step 8: Guidance Preview (EnhancedOnboarding - GUIDANCE_PREVIEW) + +- **No data collection** - This step shows a preview of personalized guidance based on previous selections + +### Step 9: Features Overview (EnhancedOnboarding - FEATURES) + +- **No data collection** - This step showcases platform features relevant to user type + +### Step 10: Newsletter (EnhancedOnboarding - NEWSLETTER) + +- **Question**: Newsletter subscription checkbox with detailed benefits +- **Saved to**: `newsletterSubscribed` (Boolean) + +### Onboarding Data Storage + +All onboarding data is processed by the `/api/user/onboarding` endpoint and stored as: + +```typescript +{ + userType, // Set in step 1 + field, // From step 2 + specializations, // From step 3 + careerStage, // Derived from step 4 experience level + newsletterSubscribed, // From step 10 + onboardingCompleted, // Set to true on completion + guidancePreferences: { + field, + specializations, + careerStage, + lastUpdated: new Date().toISOString(), + // All preferences from steps 4-7 + experienceLevel, + primaryGoals, + jobSearchStatus, + learningStyle, + timeCommitment, + pacePreference, + currentChallenges, + priorityTopics, + urgentNeeds, + reminders, + progressSharing, + mentorshipInterest, + specificChallenges, + additionalInfo + } +} ``` -For production: +### Metadata Fields -```zsh -npm run build -npm start +- **Onboarding Completion Status**: `onboardingCompleted` (Boolean) +- **Last Guidance Access**: `lastGuidanceAccess` (DateTime) + +--- + +## 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[] +} +``` + +#### 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[] +} +``` + +#### 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 +import { isUser } from "@/lib/roleUtils"; + +// Example: Get company documents +const documents = await prisma.generatedDocs.findMany({ + where: { + companyId: user.companyId, + // Additional filters based on user role + ...(isUser(user) && { createdBy: user.id }), + }, +}); +``` + +#### Role-Based Access + +```typescript +import { isAdmin } from "@/lib/roleUtils"; + +// Example: Admin can see all company data, users see only their own +const canViewAllCompanyData = isAdmin(user); +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"); +``` + +#### CV Analysis Flow + +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 + +#### Analysis Prompt Structure + +```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: + +- 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 + +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 + +- Custom company templates stored in database +- AI applies templates during generation +- Consistent branding across all outputs +- Fallback to default template structure + +#### Content Enhancement Process + +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 + +--- + +## Development Guide + +### Adding New Features + +#### 1. Database Changes + +```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 domain-specific actions in `actions/[domain].actions.ts`: + +```typescript +"use server"; + +import { auth } from "@/auth"; +import prisma from "@/prisma/prisma"; + +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 -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 +Ensure proper role and userType checking: -#### Key Benefits +```typescript +import { isAdmin } from "@/lib/roleUtils"; -- 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 +export default async function NewFeaturePage() { + const { user } = await requireAuth("/feature"); -### CV Management + // Role-based feature access + const canAccessFeature = isAdmin(user); -The CV management system allows users to: + if (!canAccessFeature) { + return ; + } -- View all generated CVs -- Filter and search through your CV library -- Make notes on individual CVs -- View, download, or share generated profiles + // Component logic... +} +``` + +### Code Quality Standards -### Template System +#### TypeScript -For companies with established CV formats, our template system allows: +- Use strict typing throughout +- Leverage `User` type from NextAuth +- Define proper interfaces for all API responses +- Avoid `any` types - create specific interfaces -- Creation of company-specific CV templates -- Template management for different roles or departments -- Consistent branding across all candidate profiles -- Customisation options for special requirements +#### Component Architecture -### User Management +```typescript +// Server component for data fetching +export default async function ServerPage() { + const data = await fetchData(); + return ; +} -Admin users can: +// 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 ; +} +``` -- Create and manage user accounts -- Assign appropriate permissions (User, Admin) -- Monitor user activity -- Configure user-specific settings +### Testing Approach -### Company Administration +#### Using Demo Accounts -For multi-user organisations, the company management features offer: +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 -- Company profile management -- User allocation and permissions within the company -- Template sharing across the organisation -- Usage reporting and analytics +#### 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 + +### Authentication Endpoints + +#### NextAuth.js Integration + +```json +POST /api/auth/[...nextauth] +``` + +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 + } +} +``` + +#### User Role Management + +```json +POST /api/user/switch-role +Content-Type: application/json -ProfilePrep implements a role-based access control system with three primary roles: +Body: +{ + "userType": "RECRUITER" | "CANDIDATE" +} -### USER +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`) -- Generate and manage their own CVs -- Use company templates (if part of a company) -- View their document history +- `getRecruiterDocuments()` - Fetch user documents +- `updateUserLimits()` - Modify document limits +- `switchUserRole()` - Handle role switching -### ADMIN +#### Admin Actions (`actions/admin.actions.ts`) -- All USER permissions -- Create and manage users within their company -- View all company CVs and templates -- Create and edit company templates +- `getCompanyUsers()` - Manage company members +- `createCompanyUser()` - Add new organization members +- `updateUserPermissions()` - Modify user roles and limits -### SUPERADMIN +### Query Hooks (`actions/queries/`) -- All ADMIN permissions -- Manage companies -- Access system-wide settings -- View analytics across all companies +#### 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 +1126,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 + +# Format schema files +npm run db:format +``` + +#### Environment Configuration + +```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 -#### Technical Implementation +#### Security Concerns -The advanced matching system will utilise: +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 -- 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 +#### Monitoring & Logging -##### Implementation Timeline #3 +```bash +# Enable detailed logging in production +export NEXTAUTH_DEBUG=true +export NODE_ENV=production -The Advanced Matching Algorithm is scheduled for release in Q2 2025, following the successful deployment and adoption of the basic job matching functionality. +# Monitor API responses and errors +# Implement error tracking (Sentry, LogRocket, etc.) +``` + +### Getting Help + +#### Internal Resources -## Contributing +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 -We welcome contributions to ProfilePrep! Here's how you can help: +#### External Resources -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 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) -Please follow our coding standards and include appropriate tests with your contributions. +#### Community Support + +1. Next.js Discord community +2. NextAuth.js GitHub discussions +3. Prisma community forums +4. Stack Overflow with relevant tags --- -## License - Pending Decision +## 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) +``` -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. +--- + +## License + +This project is proprietary software. All rights reserved. --- + +Last updated: 12 August 2025 diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..5add16d --- /dev/null +++ b/TODO.md @@ -0,0 +1,233 @@ +# ProfilePrep Feature Development Todo + +This document outlines all features and enhancements that still need to be implemented across the ProfilePrep platform. + +## Content & Guidance System + +### ✅ Completed + +- [x] Enhanced guidance content system with personalised British English content +- [x] Client-side content generation based on user preferences +- [x] Time estimation based on content length +- [x] Preference-based content personalisation +- [x] Dynamic philosophy generation + +### 🚧 In Progress + +- [ ] Complete candidate guidance content (career growth, salary negotiation topics) +- [ ] Add missing recruiter topics (job descriptions, interviewing, employer branding, etc.) +- [ ] Implement content display in guidance sections + +### 📋 Todo + +- [ ] Create comprehensive candidate guidance for all career stages +- [ ] Add industry-specific guidance variations +- [ ] Implement guided learning paths with prerequisites +- [ ] Add interactive exercises and assessments within guidance topics + +### 🗑️ Remove + +- [x] `RecruiterGuidanceClient.tsx` ✅ Removed +- [x] `CandidateGuidanceClient.tsx` ✅ Removed +- [x] `GuidancePanel.tsx` ✅ Removed +- [x] `AppWithGuidance.tsx` ✅ Removed (unused wrapper) +- [ ] `enhanced-candidate-guidance.json` + +## Settings & Preferences + +### 🚧 In Progress + +- [ ] Create editable preferences page for candidates +- [ ] Create editable preferences page for recruiters +- [ ] Allow updating of field, specialisations, and career stage +- [ ] Enable modification of guidance preferences post-onboarding + +### 📋 Todo + +- [ ] Add notification preferences (email, in-app) +- [ ] Allow data export and deletion (GDPR compliance) +- [ ] Implement account deactivation/deletion +- [ ] Add privacy settings for profile visibility + +## Candidate Features + +### ✅ Completed + +- [x] Basic CV analysis functionality +- [x] Onboarding flow with preference collection +- [x] Guidance system foundation + +### 🚧 In Progress + +- [ ] Cover letter generation based on CV and optional job description + +### 📋 Todo + +- [ ] **Practice Interview System** + - [ ] AI-powered mock interviews + - [ ] Industry-specific question sets + - [ ] Video/audio recording for self-review + - [ ] Performance feedback and improvement suggestions +- [ ] **Enhanced CV Tools** + - [ ] ATS compatibility checker with detailed feedback + - [ ] CV optimisation suggestions based on job descriptions + - [ ] Multiple CV versions for different roles/industries + - [ ] CV performance tracking (views, downloads, responses) +- [ ] **Job Application Tools** + - [ ] Job description analysis and matching + - [ ] Application tracking system + - [ ] Follow-up reminder system + - [ ] Interview scheduling integration +- [ ] **Career Development** + - [ ] Skills gap analysis + - [ ] Learning resource recommendations + - [ ] Professional development tracking + - [ ] Networking activity suggestions + +## Recruiter Features + +### ✅ Completed + +- [x] Basic guidance system +- [x] Onboarding flow + +### 📋 Todo + +- [ ] **Job Description Analyzer** + - [ ] Bias detection and inclusive language suggestions + - [ ] Clarity and readability scoring + - [ ] SEO optimisation recommendations + - [ ] Template library with best practices +- [ ] **Interview Question Bank** + - [ ] Role-specific question libraries + - [ ] Behavioural and situational question sets + - [ ] Competency-based interview frameworks + - [ ] Question effectiveness tracking +- [ ] **Market Intelligence Dashboard** + - [ ] Salary benchmarking tools + - [ ] Talent supply and demand analytics + - [ ] Competitor analysis insights + - [ ] Industry trend reports +- [ ] **Candidate Assessment Tools** + - [ ] Structured interview scorecards + - [ ] Reference checking templates + - [ ] Assessment rubrics for different roles + - [ ] Bias reduction toolkits +- [ ] **Recruitment Analytics** + - [ ] Time-to-hire tracking + - [ ] Source effectiveness analysis + - [ ] Candidate experience metrics + - [ ] Diversity and inclusion reporting + +## AI & Chatbot Features + +### 📋 Todo + +- [ ] **AI Interview Practice Bot** + - [ ] Natural language processing for realistic conversations + - [ ] Adaptive questioning based on responses + - [ ] Industry and role-specific scenarios + - [ ] Performance analysis and feedback +- [ ] **AI Career Advisor** + - [ ] Personalised career path recommendations + - [ ] Skills development suggestions + - [ ] Market opportunity identification + - [ ] Goal setting and progress tracking +- [ ] **AI Recruitment Assistant** + - [ ] Candidate screening automation + - [ ] Job description optimisation + - [ ] Interview question generation + - [ ] Candidate matching algorithms + +## Platform Infrastructure + +### 📋 Todo + +- [ ] **Enhanced Analytics** + - [ ] User engagement tracking + - [ ] Feature usage analytics + - [ ] Success metrics dashboard + - [ ] A/B testing framework +- [ ] **Integration Capabilities** + - [ ] LinkedIn integration for profile import + - [ ] Calendar integration for interview scheduling + - [ ] ATS system integrations + - [ ] Job board API connections +- [ ] **Mobile Experience** + - [ ] Progressive Web App (PWA) implementation + - [ ] Mobile-optimised guidance content + - [ ] Push notifications for mobile + - [ ] Offline functionality for guidance content +- [ ] **Collaboration Features** + - [ ] Team workspaces for recruitment teams + - [ ] Candidate feedback collection + - [ ] Internal notes and collaboration tools + - [ ] Approval workflows for job postings + +## Content Expansion + +### 📋 Todo + +- [ ] **Industry-Specific Content** + - [ ] Technology sector guidance + - [ ] Healthcare recruitment specialisation + - [ ] Finance industry focus + - [ ] Creative industries content +- [ ] **Advanced Topics** + - [ ] Executive search strategies + - [ ] Remote work recruitment + - [ ] Diversity hiring best practices + - [ ] Retention and employee engagement +- [ ] **Interactive Learning** + - [ ] Video content integration + - [ ] Interactive workshops and webinars + - [ ] Peer learning communities + - [ ] Mentorship matching system + +## Quality & Performance + +### 📋 Todo + +- [ ] **Testing & Quality Assurance** + - [ ] Comprehensive test suite for all features + - [ ] End-to-end testing for user journeys + - [ ] Performance monitoring and optimisation + - [ ] Security auditing and penetration testing +- [ ] **Accessibility & Internationalisation** + - [ ] Full WCAG compliance + - [ ] Multi-language support + - [ ] Localised content for different markets + - [ ] Cultural adaptation of guidance content +- [ ] **Fixes** + - [ ] Onboarding flow - fix stepper progress bar to top + - [ ] Onboarding flow - double previous/next button set issue + +## Priority Rankings + +### 🔥 High Priority (Q1 2025) + +1. Settings page with editable preferences +2. Cover letter generation tool +3. Job Description Analyzer for recruiters +4. Interview Question Bank +5. AI Interview Practice Bot + +### 🔶 Medium Priority (Q2 2025) + +1. Market Intelligence Dashboard +2. Enhanced CV tools (ATS checker, optimisation) +3. Practice interview system expansion +4. Mobile PWA implementation + +### 🔵 Low Priority (Q3+ 2025) + +1. Advanced integration capabilities +2. Collaboration features +3. Multi-language support +4. Executive search specialisation + +--- + +**Last Updated:** August 2025 +**Total Features Identified:** 45+ +**Completion Status:** ~15% complete diff --git a/actions/admin.actions.ts b/actions/admin.actions.ts index c42b31f..83f8eb3 100644 --- a/actions/admin.actions.ts +++ b/actions/admin.actions.ts @@ -7,6 +7,7 @@ import { User } from "next-auth"; import { handleError } from "@/lib/apiUtils"; import { getQueryClient } from "@/lib/getQueryClient"; +import { isAdmin, isSuperAdmin, getCompanyFilter, isCandidateAdmin, canAccessCompanyResource } from "@/lib/roleUtils"; interface CreateTemplateData { name: string; @@ -19,34 +20,24 @@ interface EditTemplateData { companyId?: string; } -export const fetchAllUsers = async (user: { - role: string; - companyId?: string; -}) => { +export const fetchAllUsers = async (user: User) => { // Check user role - if (user.role === "USER") { + if (!isAdmin(user)) { throw new Error( "403 Forbidden: You do not have permission to access this resource.", ); } - // If the user is an admin, fetch users from their company - if (user.role === "ADMIN") { - return await prisma.user.findMany({ - where: { - companyId: user.companyId, - }, - include: { - company: true, - }, - orderBy: { - createdAt: "desc", - }, - }); - } + // Get the appropriate filter based on role + const companyFilter = getCompanyFilter(user); + + // If superadmin with userType filter specified + const whereClause = isSuperAdmin(user) && user.userType + ? { ...companyFilter, userType: user.userType as "RECRUITER" | "CANDIDATE" | "TESTER" } + : companyFilter; - // If the user is a superadmin, fetch all users return await prisma.user.findMany({ + where: whereClause, include: { company: true, }, @@ -88,21 +79,19 @@ export const editUser = async ( sessionUser: User, ) => { // Check permissions - if (sessionUser.role === "USER") { + if (!isAdmin(sessionUser)) { return { success: false, message: "403 Forbidden: You do not have permission to edit users.", }; } - // If admin, verify the user belongs to their company - if (sessionUser.role === "ADMIN") { - if (userData.companyId !== sessionUser.company?.id) { - return { - success: false, - message: "403 Forbidden: You can only edit users from your company.", - }; - } + // If admin (not superadmin), verify the user belongs to their company + if (!canAccessCompanyResource(sessionUser, userData.companyId)) { + return { + success: false, + message: "403 Forbidden: You can only edit users from your company.", + }; } try { @@ -160,21 +149,21 @@ export const editUser = async ( export const deleteUser = async (userId: string, sessionUser: User) => { // Check permissions - if (sessionUser.role === "USER") { + if (!isAdmin(sessionUser)) { return { success: false, message: "403 Forbidden: You do not have permission to delete users.", }; } - // If admin, verify the user belongs to their company - if (sessionUser.role === "ADMIN") { + // If admin (not superadmin), verify the user belongs to their company + if (!isSuperAdmin(sessionUser)) { const userToDelete = await prisma.user.findUnique({ where: { id: userId }, select: { companyId: true }, }); - if (!userToDelete || userToDelete.companyId !== sessionUser.company?.id) { + if (!userToDelete || !canAccessCompanyResource(sessionUser, userToDelete.companyId)) { return { success: false, message: "403 Forbidden: You can only delete users from your company.", @@ -196,7 +185,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 +195,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,11 +208,11 @@ export const createCompany = async (companyData: NewCompanyData) => { export const updateCompany = async ( companyId: string, - companyData: NewCompanyData, + companyData: NewCompanyData & { companyType?: string }, sessionUser: User, ) => { // Check permissions - if (sessionUser.role !== "SUPERADMIN") { + if (!isSuperAdmin(sessionUser)) { return { success: false, message: "403 Forbidden: Only superadmins can update companies.", @@ -259,6 +253,9 @@ export const updateCompany = async ( name: companyData.name, allowedDocsPerUsers: companyData.allowedDocsPerUsers, allowedTemplates: companyData.allowedTemplates, + ...(companyData.companyType && { + companyType: companyData.companyType as "RECRUITER" | "CANDIDATE_ORG", + }), }, }); @@ -284,7 +281,7 @@ export const updateCompany = async ( export const deleteCompany = async (companyId: string, sessionUser: User) => { // Check permissions - if (sessionUser.role !== "SUPERADMIN") { + if (!isSuperAdmin(sessionUser)) { return { success: false, message: "403 Forbidden: Only superadmins can delete companies.", @@ -331,14 +328,22 @@ export const deleteCompany = async (companyId: string, sessionUser: User) => { } }; -export const fetchAllCompanies = async (sessionUser: User) => { - if (sessionUser.role !== "SUPERADMIN") { +export const fetchAllCompanies = async ( + sessionUser: User, + companyType?: string, +) => { + if (!isSuperAdmin(sessionUser)) { 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: { @@ -365,7 +370,7 @@ export const fetchAllCompanies = async (sessionUser: User) => { }; export async function getAllUserDocs(sessionUser: User) { - if (sessionUser.role !== "SUPERADMIN") { + if (!isSuperAdmin(sessionUser)) { return { success: false, message: "403 Forbidden: You do not have permission to delete users.", @@ -407,12 +412,10 @@ export async function getAllUserDocs(sessionUser: User) { export async function fetchAllTemplates(sessionUser: User) { try { - // If admin or user, fetch only company templates - if (sessionUser.role === "ADMIN" || sessionUser.role === "USER") { + // If not superadmin, fetch only company templates + if (!isSuperAdmin(sessionUser)) { const templates = await prisma.template.findMany({ - where: { - companyId: sessionUser.company?.id, - }, + where: getCompanyFilter(sessionUser), select: { id: true, name: true, @@ -494,7 +497,7 @@ export const createTemplate = async ( sessionUser: User, ) => { // Check permissions - if (sessionUser.role === "USER") { + if (!isAdmin(sessionUser)) { return { success: false, message: "403 Forbidden: You do not have permission to create templates.", @@ -502,10 +505,10 @@ export const createTemplate = async ( } try { - // If admin, verify the template is being created for their company + // If admin (not superadmin), verify the template is being created for their company if ( - sessionUser.role === "ADMIN" && - templateData.companyId !== sessionUser.company?.id + !isSuperAdmin(sessionUser) && + !canAccessCompanyResource(sessionUser, templateData.companyId) ) { return { success: false, @@ -569,7 +572,7 @@ export const editTemplate = async ( sessionUser: User, ) => { // Check permissions - if (sessionUser.role === "USER") { + if (!isAdmin(sessionUser)) { return { success: false, message: "403 Forbidden: You do not have permission to edit templates.", @@ -590,10 +593,10 @@ export const editTemplate = async ( }; } - // If admin, verify they're editing a template from their company + // If admin (not superadmin), verify they're editing a template from their company if ( - sessionUser.role === "ADMIN" && - currentTemplate.companyId !== sessionUser.company?.id + !isSuperAdmin(sessionUser) && + !canAccessCompanyResource(sessionUser, currentTemplate.companyId) ) { return { success: false, @@ -602,14 +605,14 @@ export const editTemplate = async ( }; } - // If admin, ensure they can't change the company - if (sessionUser.role === "ADMIN" && templateData.companyId) { + // If admin (not superadmin), ensure they can't change the company + if (!isSuperAdmin(sessionUser) && templateData.companyId) { delete templateData.companyId; } // If superadmin is changing company, verify the new company exists if ( - sessionUser.role === "SUPERADMIN" && + isSuperAdmin(sessionUser) && templateData.companyId && templateData.companyId !== currentTemplate.companyId ) { @@ -674,7 +677,7 @@ export const editTemplate = async ( export const deleteTemplate = async (templateId: string, sessionUser: User) => { // Check permissions - if (sessionUser.role !== "SUPERADMIN") { + if (!isSuperAdmin(sessionUser)) { return { success: false, message: "403 Forbidden: Only Super Admins can delete templates.", @@ -718,3 +721,116 @@ export const deleteTemplate = async (templateId: string, sessionUser: User) => { return handleError(error); } }; + +// Candidate organization actions +export const getOrganizationMembers = async (user: User) => { + if (!isCandidateAdmin(user)) { + throw new Error( + "Unauthorized: Insufficient permissions for candidate organization access", + ); + } + + const companyFilter = getCompanyFilter(user); + if (!companyFilter.companyId) { + throw new Error("User not associated with any organization"); + } + + return await prisma.user.findMany({ + where: { + ...companyFilter, + 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 (!isCandidateAdmin(user)) { + throw new Error( + "Unauthorized: Insufficient permissions for candidate organization access", + ); + } + + const companyFilter = getCompanyFilter(user); + if (!companyFilter.companyId) { + return []; + } + + return await prisma.cVAnalysis.findMany({ + where: { + user: { + ...companyFilter, + userType: "CANDIDATE", + }, + }, + include: { + user: { + select: { + name: true, + email: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); +}; + +export const getOrganizationAnalytics = async (user: User) => { + if (!isCandidateAdmin(user)) { + 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/ai.actions.ts b/actions/ai.actions.ts index e171fad..c194aea 100644 --- a/actions/ai.actions.ts +++ b/actions/ai.actions.ts @@ -15,6 +15,7 @@ import { generateText } from "ai"; import { User } from "next-auth"; import { logTokenUsage } from "@/lib/utils"; +import { getCompanyFilter } from "@/lib/roleUtils"; const selectedModel = google("gemini-2.0-flash-001"); @@ -78,11 +79,12 @@ export const generateDocument = async ( logTokenUsage(response.usage, "Document Generation"); incrementUserGenerations(user.id!); + const companyFilter = getCompanyFilter(user); await createGeneratedDoc({ documentContent: candidateInfo, rawContent: cleanedText, userId: user.id!, - companyId: user.company?.id || " ", + companyId: companyFilter.companyId || " ", }); return cleanedText; diff --git a/actions/analytics.actions.ts b/actions/analytics.actions.ts index 7d05096..c2fbc51 100644 --- a/actions/analytics.actions.ts +++ b/actions/analytics.actions.ts @@ -3,7 +3,7 @@ import prisma from "@/prisma/prisma"; import type { User } from "next-auth"; -import { isSuperAdmin } from "@/lib/roleUtils"; +import { isSuperAdmin, getCompanyFilter } from "@/lib/roleUtils"; export async function getTopCompanies(user: User) { // monthly document generation counts for the last 6 months @@ -56,10 +56,11 @@ export async function getTopCompanies(user: User) { // NOTE: departments/teams can be shown within their company pending expansion else { // top users in their company instead of companies (admins) + const companyFilter = getCompanyFilter(user); const topUsers = await prisma.generatedDocs.groupBy({ by: ["createdBy"], where: { - companyId: user.company?.id, + ...companyFilter, createdAt: { gte: sixMonthsAgo, }, @@ -121,9 +122,7 @@ export async function getActiveUsers(user: User) { // only users in their company (admins) return await prisma.user.findMany({ - where: { - companyId: user.company?.id, - }, + where: getCompanyFilter(user), orderBy: { createdDocs: "desc", }, @@ -157,9 +156,7 @@ export async function getTemplateUsage(user: User) { // templates in their company (admins) return await prisma.template.findMany({ - where: { - companyId: user.company?.id, - }, + where: getCompanyFilter(user), select: { id: true, name: true, 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/guidance.actions.ts b/actions/guidance.actions.ts new file mode 100644 index 0000000..86e5255 --- /dev/null +++ b/actions/guidance.actions.ts @@ -0,0 +1,757 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { auth } from "@/auth"; +import prisma from "@/prisma/prisma"; +import { UserType } from "@prisma/client"; + +import { GuidancePreferences } from "@/lib/guidance/content/types"; +import { isCandidate } from "@/lib/roleUtils"; + +// ==================== QUERY FUNCTIONS (READ) ==================== + +// Get user's guidance progress for all topics +export async function getUserGuidanceProgress(userType?: UserType) { + try { + const session = await auth(); + if (!session?.user?.email) { + throw new Error("Unauthorized"); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { id: true, userType: true }, + }); + + if (!user) { + throw new Error("User not found"); + } + + const progress = await prisma.guidanceProgress.findMany({ + where: { + userId: user.id, + userType: userType || user.userType, + }, + orderBy: { lastAccessed: "desc" }, + }); + + return { success: true, data: progress }; + } catch (error) { + console.error("Error fetching guidance progress:", error); + return { success: false, error: "Failed to fetch guidance progress" }; + } +} + +// Get guidance analytics for a user +export async function getGuidanceAnalytics() { + try { + const session = await auth(); + if (!session?.user?.email) { + throw new Error("Unauthorized"); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { id: true }, + }); + + if (!user) { + throw new Error("User not found"); + } + + const analytics = await prisma.guidanceAnalytics.findUnique({ + where: { userId: user.id }, + }); + + if (!analytics) { + // Create initial analytics record + const newAnalytics = await prisma.guidanceAnalytics.create({ + data: { + userId: user.id, + totalTopicsStarted: 0, + totalTopicsCompleted: 0, + totalTimeSpent: 0, + averageProgress: 0, + streakDays: 0, + lastActiveDate: new Date(), + preferredTopics: [], + }, + }); + return { success: true, data: newAnalytics }; + } + + return { success: true, data: analytics }; + } catch (error) { + console.error("Error fetching guidance analytics:", error); + return { success: false, error: "Failed to fetch guidance analytics" }; + } +} + +// Get user's guidance preferences for personalization +export async function getUserGuidancePreferences() { + try { + const session = await auth(); + if (!session?.user?.email) { + throw new Error("Unauthorized"); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { + guidancePreferences: true, + }, + }); + + if (!user) { + throw new Error("User not found"); + } + + return { + success: true, + data: user.guidancePreferences as GuidancePreferences | null, + }; + } catch (error) { + console.error("Error fetching guidance preferences:", error); + return { success: false, error: "Failed to fetch guidance preferences" }; + } +} + +// Get personalised recommendations based on user data and progress +export async function getPersonalizedRecommendations() { + try { + const session = await auth(); + if (!session?.user?.email) { + throw new Error("Unauthorized"); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { + id: true, + userType: true, + field: true, + specializations: true, + careerStage: true, + guidancePreferences: true, + GuidanceProgress: true, + }, + }); + + if (!user) { + throw new Error("User not found"); + } + + // Get completed topic IDs + const completedTopics = user.GuidanceProgress.filter( + (p) => p.completed, + ).map((p) => p.topicId); + + // Get topics in progress + const inProgressTopics = user.GuidanceProgress.filter( + (p) => !p.completed && p.progress > 0, + ).map((p) => p.topicId); + + // Define topic dependencies/prerequisites + const topicPrerequisites: { [key: string]: string[] } = { + linkedin: ["cv-optimization"], + networking: ["linkedin"], + "career-growth": ["networking", "linkedin"], + "salary-negotiation": ["interview-prep"], + interviewing: ["sourcing"], + retention: ["candidate-experience"], + }; + + // Generate recommendations based on user profile, progress, and preferences + const preferences = user.guidancePreferences as GuidancePreferences | null; + let recommendations: string[] = []; + + // Start with preference-based recommendations if available + if (preferences?.priorityTopics?.length) { + recommendations = [...preferences.priorityTopics]; + } else if (preferences?.urgentNeeds?.length) { + recommendations = [...preferences.urgentNeeds]; + } + + // Supplement with profile-based recommendations if needed + if (recommendations.length < 3) { + let profileRecommendations: string[] = []; + + if (isCandidate(user)) { + // Enhanced candidate recommendations based on preferences + if (preferences?.jobSearchStatus === "active") { + profileRecommendations = [ + "cv-optimization", + "interview-prep", + "cover-letters", + ]; + } else if (preferences?.primaryGoals?.includes("career_change")) { + profileRecommendations = [ + "cv-optimization", + "networking", + "skill-development", + ]; + } else if (preferences?.primaryGoals?.includes("promotion")) { + profileRecommendations = [ + "career-growth", + "networking", + "salary-negotiation", + ]; + } else if (user.careerStage === "earlyCareer") { + profileRecommendations = [ + "cv-optimization", + "interview-prep", + "linkedin", + ]; + } else if (user.careerStage === "midCareer") { + profileRecommendations = [ + "career-growth", + "salary-negotiation", + "networking", + ]; + } else if (user.careerStage === "seniorCareer") { + profileRecommendations = [ + "career-growth", + "market-insights", + "linkedin", + ]; + } else if (user.careerStage === "careerChanger") { + profileRecommendations = [ + "cv-optimization", + "cover-letters", + "networking", + ]; + } else { + profileRecommendations = [ + "cv-optimization", + "interview-prep", + "linkedin", + ]; + } + } else { + // RECRUITER recommendations enhanced with preferences + if (preferences?.primaryGoals?.includes("sourcing_improvement")) { + profileRecommendations = ["sourcing", "screening", "interviewing"]; + } else if ( + preferences?.currentChallenges?.includes("candidate_assessment") + ) { + profileRecommendations = [ + "screening", + "interviewing", + "market-insights", + ]; + } else if (user.field === "technology") { + profileRecommendations = ["sourcing", "screening", "interviewing"]; + } else if (user.field === "healthcare") { + profileRecommendations = [ + "compliance", + "candidate-experience", + "retention", + ]; + } else if (user.field === "finance") { + profileRecommendations = [ + "employer-branding", + "market-insights", + "interviewing", + ]; + } else { + profileRecommendations = [ + "sourcing", + "job-descriptions", + "diversity", + ]; + } + } + + // Add profile recommendations that aren't already included + profileRecommendations.forEach((rec) => { + if (!recommendations.includes(rec)) { + recommendations.push(rec); + } + }); + } + + // Filter out completed topics and ensure prerequisites are met + const eligibleRecommendations = recommendations.filter((topicId) => { + if (completedTopics.includes(topicId)) return false; + + const prerequisites = topicPrerequisites[topicId] || []; + const prerequisitesMet = prerequisites.every((prereq) => + completedTopics.includes(prereq), + ); + + return prerequisitesMet; + }); + + // Prioritise topics in progress + const prioritizedRecommendations = [ + ...inProgressTopics.filter((id) => eligibleRecommendations.includes(id)), + ...eligibleRecommendations.filter((id) => !inProgressTopics.includes(id)), + ].slice(0, 3); + + return { + success: true, + data: { + recommendations: prioritizedRecommendations, + preferences: preferences, + reasoningContext: { + hasPreferences: !!preferences, + jobSearchStatus: preferences?.jobSearchStatus, + primaryGoals: preferences?.primaryGoals, + urgentNeeds: preferences?.urgentNeeds, + }, + }, + }; + } catch (error) { + console.error("Error generating recommendations:", error); + return { success: false, error: "Failed to generate recommendations" }; + } +} + +// Get guidance progress for a specific topic +export async function getTopicProgress(topicId: string, userType?: UserType) { + try { + const session = await auth(); + if (!session?.user?.email) { + throw new Error("Unauthorized"); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { id: true, userType: true }, + }); + + if (!user) { + throw new Error("User not found"); + } + + const progress = await prisma.guidanceProgress.findUnique({ + where: { + userId_topicId_userType: { + userId: user.id, + topicId, + userType: userType || user.userType, + }, + }, + }); + + return { success: true, data: progress }; + } catch (error) { + console.error("Error fetching topic progress:", error); + return { success: false, error: "Failed to fetch topic progress" }; + } +} + +// Get user's bookmarked topics +export async function getBookmarkedTopics(userType?: UserType) { + try { + const session = await auth(); + if (!session?.user?.email) { + throw new Error("Unauthorized"); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { id: true, userType: true }, + }); + + if (!user) { + throw new Error("User not found"); + } + + const bookmarkedProgress = await prisma.guidanceProgress.findMany({ + where: { + userId: user.id, + userType: userType || user.userType, + bookmarked: true, + }, + orderBy: { lastAccessed: "desc" }, + }); + + return { success: true, data: bookmarkedProgress }; + } catch (error) { + console.error("Error fetching bookmarked topics:", error); + return { success: false, error: "Failed to fetch bookmarked topics" }; + } +} + +// Get guidance statistics for admin dashboard +export async function getGuidanceStats() { + try { + const session = await auth(); + if (!session?.user?.email) { + throw new Error("Unauthorized"); + } + + // Check if user is admin (you might want to add this check based on your auth system) + const totalUsers = await prisma.user.count(); + const usersWithProgress = await prisma.user.count({ + where: { + GuidanceProgress: { + some: {}, + }, + }, + }); + + const totalProgress = await prisma.guidanceProgress.count(); + const completedTopics = await prisma.guidanceProgress.count({ + where: { completed: true }, + }); + + const averageTimeSpent = await prisma.guidanceProgress.aggregate({ + _avg: { + timeSpent: true, + }, + }); + + const topTopics = await prisma.guidanceProgress.groupBy({ + by: ["topicId"], + _count: { + topicId: true, + }, + orderBy: { + _count: { + topicId: "desc", + }, + }, + take: 5, + }); + + return { + success: true, + data: { + totalUsers, + usersWithProgress, + engagementRate: + totalUsers > 0 ? (usersWithProgress / totalUsers) * 100 : 0, + totalProgress, + completedTopics, + completionRate: + totalProgress > 0 ? (completedTopics / totalProgress) * 100 : 0, + averageTimeSpent: averageTimeSpent._avg.timeSpent || 0, + topTopics: topTopics.map((topic) => ({ + topicId: topic.topicId, + count: topic._count.topicId, + })), + }, + }; + } catch (error) { + console.error("Error fetching guidance stats:", error); + return { success: false, error: "Failed to fetch guidance stats" }; + } +} + +// Get user profile data for content generation (server-side data only) +export async function getUserProfileForContent(userType?: UserType) { + try { + const session = await auth(); + if (!session?.user?.email) { + throw new Error("Unauthorized"); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { + id: true, + userType: true, + field: true, + specializations: true, + careerStage: true, + guidancePreferences: true, + }, + }); + + if (!user) { + throw new Error("User not found"); + } + + const effectiveUserType = userType || user.userType; + const preferences = user.guidancePreferences as GuidancePreferences | null; + + return { + success: true, + data: { + preferences, + userProfile: { + field: user.field || "general", + specializations: user.specializations || [], + careerStage: user.careerStage || "general", + userType: + effectiveUserType === "TESTER" ? "CANDIDATE" : effectiveUserType, + }, + }, + }; + } catch (error) { + console.error(`Error getting user profile for content generation:`, error); + return { success: false, error: "Failed to get user profile for content" }; + } +} + +// ==================== MUTATION FUNCTIONS (WRITE) ==================== + +export interface GuidanceProgressData { + topicId: string; + progress: number; + completed: boolean; + sectionsCompleted: string[]; + timeSpent: number; + bookmarked?: boolean; +} + +export interface GuidanceAnalyticsData { + totalTopicsStarted: number; + totalTopicsCompleted: number; + totalTimeSpent: number; + averageProgress: number; + streakDays: number; + lastActiveDate: Date; + preferredTopics: string[]; +} + +// Update or create guidance progress for a specific topic +export async function updateGuidanceProgress(data: GuidanceProgressData) { + try { + const session = await auth(); + if (!session?.user?.email) { + throw new Error("Unauthorized"); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { id: true, userType: true }, + }); + + if (!user) { + throw new Error("User not found"); + } + + // Update or create progress record + const progressRecord = await prisma.guidanceProgress.upsert({ + where: { + userId_topicId_userType: { + userId: user.id, + topicId: data.topicId, + userType: user.userType, + }, + }, + update: { + progress: data.progress, + completed: data.completed, + sectionsCompleted: data.sectionsCompleted, + timeSpent: data.timeSpent, + bookmarked: data.bookmarked ?? false, + lastAccessed: new Date(), + }, + create: { + userId: user.id, + topicId: data.topicId, + userType: user.userType, + progress: data.progress, + completed: data.completed, + sectionsCompleted: data.sectionsCompleted, + timeSpent: data.timeSpent, + bookmarked: data.bookmarked ?? false, + lastAccessed: new Date(), + }, + }); + + // Update analytics + await updateGuidanceAnalytics(user.id); + + // Update user's last guidance access + await prisma.user.update({ + where: { id: user.id }, + data: { lastGuidanceAccess: new Date() }, + }); + + revalidatePath("/portal/guidance"); + revalidatePath("/recruiter/guidance"); + + return { success: true, data: progressRecord }; + } catch (error) { + console.error("Error updating guidance progress:", error); + return { success: false, error: "Failed to update guidance progress" }; + } +} + +// Toggle bookmark status for a topic +export async function toggleGuidanceBookmark( + topicId: string, + bookmarked: boolean, +) { + try { + const session = await auth(); + if (!session?.user?.email) { + throw new Error("Unauthorized"); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { id: true, userType: true }, + }); + + if (!user) { + throw new Error("User not found"); + } + + const progressRecord = await prisma.guidanceProgress.upsert({ + where: { + userId_topicId_userType: { + userId: user.id, + topicId, + userType: user.userType, + }, + }, + update: { + bookmarked, + lastAccessed: new Date(), + }, + create: { + userId: user.id, + topicId, + userType: user.userType, + progress: 0, + completed: false, + sectionsCompleted: [], + timeSpent: 0, + bookmarked, + lastAccessed: new Date(), + }, + }); + + revalidatePath("/portal/guidance"); + revalidatePath("/recruiter/guidance"); + + return { success: true, data: progressRecord }; + } catch (error) { + console.error("Error toggling bookmark:", error); + return { success: false, error: "Failed to toggle bookmark" }; + } +} + +// Internal function to update guidance analytics +async function updateGuidanceAnalytics(userId: string) { + try { + // Get all progress records for the user + const progressRecords = await prisma.guidanceProgress.findMany({ + where: { userId }, + }); + + const totalTopicsStarted = progressRecords.filter( + (p) => p.progress > 0, + ).length; + const totalTopicsCompleted = progressRecords.filter( + (p) => p.completed, + ).length; + const totalTimeSpent = progressRecords.reduce( + (sum, p) => sum + p.timeSpent, + 0, + ); + const averageProgress = + progressRecords.length > 0 + ? progressRecords.reduce((sum, p) => sum + p.progress, 0) / + progressRecords.length + : 0; + + // Calculate streak days (simplified - count consecutive days with activity) + const sortedRecords = progressRecords.sort( + (a, b) => b.lastAccessed.getTime() - a.lastAccessed.getTime(), + ); + + let streakDays = 0; + let currentDate = new Date(); + currentDate.setHours(0, 0, 0, 0); + + for (const record of sortedRecords) { + const recordDate = new Date(record.lastAccessed); + recordDate.setHours(0, 0, 0, 0); + + const daysDiff = + Math.abs(currentDate.getTime() - recordDate.getTime()) / + (1000 * 60 * 60 * 24); + + if (daysDiff <= 1) { + streakDays++; + currentDate = recordDate; + } else { + break; + } + } + + // Calculate preferred topics based on engagement (time spent + completion rate) + const topicEngagement = progressRecords.map((p) => ({ + topicId: p.topicId, + engagement: p.timeSpent * (p.completed ? 2 : 1) + p.progress, + })); + + const preferredTopics = topicEngagement + .sort((a, b) => b.engagement - a.engagement) + .slice(0, 3) + .map((t) => t.topicId); + + // Update or create analytics record + await prisma.guidanceAnalytics.upsert({ + where: { userId }, + update: { + totalTopicsStarted, + totalTopicsCompleted, + totalTimeSpent, + averageProgress, + streakDays, + lastActiveDate: new Date(), + preferredTopics, + }, + create: { + userId, + totalTopicsStarted, + totalTopicsCompleted, + totalTimeSpent, + averageProgress, + streakDays, + lastActiveDate: new Date(), + preferredTopics, + }, + }); + } catch (error) { + console.error("Error updating guidance analytics:", error); + } +} + +// Clear all progress (for testing/reset purposes) +export async function clearGuidanceProgress() { + try { + const session = await auth(); + if (!session?.user?.email) { + throw new Error("Unauthorized"); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { id: true }, + }); + + if (!user) { + throw new Error("User not found"); + } + + await prisma.guidanceProgress.deleteMany({ + where: { userId: user.id }, + }); + + await prisma.guidanceAnalytics + .delete({ + where: { userId: user.id }, + }) + .catch(() => { + // Ignore if analytics record doesn't exist + }); + + revalidatePath("/portal/guidance"); + revalidatePath("/recruiter/guidance"); + + return { success: true }; + } catch (error) { + console.error("Error clearing guidance progress:", error); + return { success: false, error: "Failed to clear guidance progress" }; + } +} diff --git a/actions/queries/admin.queries.ts b/actions/queries/admin.queries.ts index 501171c..fdef350 100644 --- a/actions/queries/admin.queries.ts +++ b/actions/queries/admin.queries.ts @@ -1,10 +1,18 @@ "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"; +import { isCandidateAdmin, hasCompanyAccess } from "@/lib/roleUtils"; + export const useUsersQuery = (sessionUser: User) => { return useQuery({ queryKey: ["users"], @@ -26,3 +34,31 @@ 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: !!(hasCompanyAccess(user) && isCandidateAdmin(user)), + staleTime: 1000 * 60 * 5, // 5 minutes + }); +}; + +export const useOrganizationAnalysesQuery = (user: User) => { + return useQuery({ + queryKey: ["organizationAnalyses", user.company?.id], + queryFn: () => getOrganizationAnalyses(user), + enabled: !!(hasCompanyAccess(user) && isCandidateAdmin(user)), + staleTime: 1000 * 60 * 5, // 5 minutes + }); +}; + +export const useOrganizationAnalyticsQuery = (user: User) => { + return useQuery({ + queryKey: ["organizationAnalytics", user.company?.id], + queryFn: () => getOrganizationAnalytics(user), + enabled: !!(hasCompanyAccess(user) && isCandidateAdmin(user)), + 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/guidance.queries.ts b/actions/queries/guidance.queries.ts new file mode 100644 index 0000000..8f7449c --- /dev/null +++ b/actions/queries/guidance.queries.ts @@ -0,0 +1,203 @@ +"use client"; + +import { + getBookmarkedTopics, + getGuidanceAnalytics, + getPersonalizedRecommendations, + getTopicProgress, + getUserGuidancePreferences, + getUserGuidanceProgress, + getUserProfileForContent, + toggleGuidanceBookmark, + updateGuidanceProgress, +} from "@/actions/guidance.actions"; +import type { UserType } from "@prisma/client"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +// Query hook for user's guidance progress +export const useGuidanceProgressQuery = (userType?: UserType) => { + return useQuery({ + queryKey: ["guidance-progress", userType], + queryFn: () => getUserGuidanceProgress(userType), + staleTime: 1000 * 60 * 10, // 10 minutes (shorter) + gcTime: 1000 * 60 * 60, // 1 hour + refetchOnWindowFocus: false, + }); +}; + +// Query hook for guidance analytics +export const useGuidanceAnalyticsQuery = () => { + return useQuery({ + queryKey: ["guidance-analytics"], + queryFn: getGuidanceAnalytics, + staleTime: 1000 * 60 * 10, // 10 minutes + gcTime: 1000 * 60 * 60, // 1 hour + }); +}; + +// Query hook for user guidance preferences +export const useGuidancePreferencesQuery = () => { + return useQuery({ + queryKey: ["guidance-preferences"], + queryFn: getUserGuidancePreferences, + staleTime: 1000 * 60 * 60, // 1 hour + gcTime: 1000 * 60 * 60 * 2, // 2 hours + }); +}; + +// Query hook for personalized recommendations +export const usePersonalizedRecommendationsQuery = () => { + return useQuery({ + queryKey: ["guidance-recommendations"], + queryFn: getPersonalizedRecommendations, + staleTime: 1000 * 60 * 30, // 30 minutes + gcTime: 1000 * 60 * 60, // 1 hour + }); +}; + +// Query hook for user profile for content +export const useUserProfileForContentQuery = (userType?: UserType) => { + return useQuery({ + queryKey: ["user-profile-content", userType], + queryFn: () => getUserProfileForContent(userType), + staleTime: 1000 * 60 * 60 * 24, // 24 hours + gcTime: 1000 * 60 * 60 * 48, // 48 hours + }); +}; + +// Query hook for specific topic progress +export const useTopicProgressQuery = ( + topicId: string, + userType?: UserType, + enabled = true, +) => { + return useQuery({ + queryKey: ["topic-progress", topicId, userType], + queryFn: () => getTopicProgress(topicId, userType), + staleTime: 1000 * 60 * 5, // 5 minutes (more aggressive) + gcTime: 1000 * 60 * 30, // 30 minutes + refetchOnWindowFocus: false, + retry: 1, + enabled, + }); +}; + +// Query hook for bookmarked topics +export const useBookmarkedTopicsQuery = (userType?: UserType) => { + return useQuery({ + queryKey: ["bookmarked-topics", userType], + queryFn: () => getBookmarkedTopics(userType), + staleTime: 1000 * 60 * 5, // 5 minutes (more aggressive) + gcTime: 1000 * 60 * 30, // 30 minutes (shorter garbage collection) + refetchOnWindowFocus: false, // Don't refetch on window focus + retry: 1, // Only retry once on failure + }); +}; + +// Mutation hook for updating guidance progress +export const useUpdateGuidanceProgressMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateGuidanceProgress, + onSuccess: (result) => { + if (result.success) { + // Invalidate relevant queries + queryClient.invalidateQueries({ queryKey: ["guidance-progress"] }); + queryClient.invalidateQueries({ queryKey: ["guidance-analytics"] }); + queryClient.invalidateQueries({ + queryKey: ["guidance-recommendations"], + }); + queryClient.invalidateQueries({ queryKey: ["topic-progress"] }); + } + }, + }); +}; + +// Mutation hook for toggling bookmark +export const useToggleBookmarkMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + topicId, + bookmarked, + }: { + topicId: string; + bookmarked: boolean; + }) => toggleGuidanceBookmark(topicId, bookmarked), + onMutate: async ({ topicId, bookmarked }) => { + // Optimistic update for better UX + await queryClient.cancelQueries({ queryKey: ["bookmarked-topics"] }); + await queryClient.cancelQueries({ queryKey: ["guidance-progress"] }); + + // Store previous data for rollback + const previousBookmarkedData = queryClient.getQueryData([ + "bookmarked-topics", + ]); + const previousProgressData = queryClient.getQueryData([ + "guidance-progress", + ]); + + // Optimistically update bookmarked data + queryClient.setQueryData(["bookmarked-topics"], (old: unknown) => { + const oldData = old as + | { + success?: boolean; + data?: Array<{ topicId: string; bookmarked: boolean }>; + } + | undefined; + if (oldData?.success && oldData?.data) { + if (bookmarked) { + // Add to bookmarks if not already there + const exists = oldData.data.some( + (item) => item.topicId === topicId, + ); + if (!exists) { + return { + ...oldData, + data: [...oldData.data, { topicId, bookmarked: true }], + }; + } + } else { + // Remove from bookmarks + return { + ...oldData, + data: oldData.data.filter((item) => item.topicId !== topicId), + }; + } + } + return old; + }); + + return { previousBookmarkedData, previousProgressData }; + }, + onSuccess: (result) => { + if (result.success) { + // Invalidate queries to ensure consistency + queryClient.invalidateQueries({ queryKey: ["guidance-progress"] }); + queryClient.invalidateQueries({ queryKey: ["bookmarked-topics"] }); + } + }, + onError: (_, __, context) => { + // Rollback optimistic updates on error + if (context?.previousBookmarkedData) { + queryClient.setQueryData( + ["bookmarked-topics"], + context.previousBookmarkedData, + ); + } + if (context?.previousProgressData) { + queryClient.setQueryData( + ["guidance-progress"], + context.previousProgressData, + ); + } + }, + onSettled: () => { + // Ensure data is fresh after mutation + queryClient.invalidateQueries({ queryKey: ["bookmarked-topics"] }); + queryClient.invalidateQueries({ queryKey: ["guidance-progress"] }); + }, + }); +}; diff --git a/actions/queries/user.queries.ts b/actions/queries/user.queries.ts index 685fbc2..ee100cc 100644 --- a/actions/queries/user.queries.ts +++ b/actions/queries/user.queries.ts @@ -1,10 +1,16 @@ "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"; +import { hasCompanyAccess } from "@/lib/roleUtils"; + export const useUserDocsQuery = (userId: string) => { return useQuery({ queryKey: ["userDocs", userId], @@ -24,8 +30,33 @@ export const useDocContentQuery = (docId: string, enabled = false) => { export const useTemplatesQuery = (user: User) => { return useQuery({ + // keep for cache key queryKey: ["templates", user.company?.id], queryFn: () => fetchAllTemplates(user), - enabled: !!user.company, + enabled: hasCompanyAccess(user), + }); +}; + +export const useRecruiterDocumentsQuery = (userId: string) => { + return useQuery({ + queryKey: ["recruiterDocuments", userId], + queryFn: () => getRecruiterDocuments(userId), + enabled: !!userId, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +}; + +export const useUserProfileQuery = () => { + return useQuery({ + queryKey: ["userProfile"], + queryFn: async () => { + const response = await fetch("/api/user/profile"); + if (!response.ok) { + throw new Error("Failed to fetch user profile"); + } + return response.json(); + }, + staleTime: 1000 * 60 * 60, // 1 hour + gcTime: 1000 * 60 * 60 * 2, // 2 hours }); }; diff --git a/actions/stats.actions.ts b/actions/stats.actions.ts index 2a9563b..9dda103 100644 --- a/actions/stats.actions.ts +++ b/actions/stats.actions.ts @@ -3,19 +3,11 @@ import prisma from "@/prisma/prisma"; import type { User } from "next-auth"; -import { getCompanyFilter, isSuperAdmin } from "@/lib/roleUtils"; +import { getCompanyFilter, isSuperAdmin, isAdmin, getUserFilter, isCandidate } from "@/lib/roleUtils"; export async function getTotalUsers(user: User) { - // count all users (superadmins) - if (isSuperAdmin(user)) { - return await prisma.user.count(); - } - - // count only users in their company (admins) return await prisma.user.count({ - where: { - companyId: user.company?.id, - }, + where: getUserFilter(user), }); } @@ -25,10 +17,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, + ...getCompanyFilter(user), + user: { + userType: user.userType, + }, }, }); } @@ -44,10 +39,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, + ...(isAdmin(user) && !isSuperAdmin(user) && { + user: { + userType: user.userType, + }, + }), createdAt: { gte: thirtyDaysAgo, }, @@ -65,6 +65,11 @@ export async function getDocsWithTrend(user: User, userId?: string) { where: { ...userFilter, ...companyFilter, + ...(isAdmin(user) && !isSuperAdmin(user) && { + user: { + userType: user.userType, + }, + }), createdAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo, @@ -104,7 +109,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 +124,11 @@ export async function getRecentActivity(user: User, userId?: string) { where: { ...userFilter, ...companyFilter, + ...(isAdmin(user) && !isSuperAdmin(user) && { + user: { + userType: user.userType, + }, + }), }, take: 5, orderBy: { @@ -157,15 +167,14 @@ export async function getSystemStats(user: User) { } else { // only their company's templates (admins) totalTemplates = await prisma.template.count({ - where: { - companyId: user.company?.id, - }, + where: getCompanyFilter(user), }); // allowed templates for this company + const companyFilter = getCompanyFilter(user); const company = await prisma.company.findUnique({ where: { - id: user.company?.id, + id: companyFilter.companyId, }, select: { allowedTemplates: true, @@ -186,11 +195,9 @@ 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, - }, + where: getUserFilter(user), _sum: { allowedDocs: true, }, @@ -230,3 +237,118 @@ export async function getUserStats(userId?: string) { recentDocs, }; } + +// Candidate-specific statistics functions +export async function getCandidateStats(user: User) { + if (!isCandidate(user)) { + 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 + const baseCompanyFilter = getCompanyFilter(user); + let companyFilter = {}; + if (isAdmin(user) && !isSuperAdmin(user) && baseCompanyFilter.companyId) { + companyFilter = { + user: { + ...baseCompanyFilter, + userType: "CANDIDATE", + }, + }; + } + + const isAdminNotSuper = isAdmin(user) && !isSuperAdmin(user); + const totalAnalyses = await prisma.cVAnalysis.count({ + where: isAdminNotSuper ? companyFilter : userFilter, + }); + + // Get average score for the candidate or organization + const analysesWithScores = await prisma.cVAnalysis.findMany({ + where: isAdminNotSuper ? 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: { + ...(isAdminNotSuper ? 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/_styles/globals.css b/app/_styles/globals.css index e6ac451..b02dcdf 100644 --- a/app/_styles/globals.css +++ b/app/_styles/globals.css @@ -33,6 +33,16 @@ body { --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; --radius: 0.5rem; + + /* Candidate personality - Personal & warm B2C */ + --candidate-bg-primary: 217 100% 97%; + --candidate-bg-secondary: 225 100% 98%; + --candidate-accent: 217 91% 85%; + + /* Recruiter personality - Professional & structured B2B */ + --recruiter-bg-primary: 210 40% 96%; + --recruiter-bg-secondary: 210 40% 98%; + --recruiter-accent: 210 50% 85%; } .dark { --background: 0 0% 3.9%; @@ -59,6 +69,15 @@ body { --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; + + /* Dark mode personality variants */ + --candidate-bg-primary: 217 91% 8%; + --candidate-bg-secondary: 225 91% 6%; + --candidate-accent: 217 91% 25%; + + --recruiter-bg-primary: 210 40% 6%; + --recruiter-bg-secondary: 210 40% 4%; + --recruiter-accent: 210 50% 25%; } } @@ -78,6 +97,28 @@ body { .layout { @apply ml-auto mr-auto max-w-7xl; } + + /* Landing page personality styles */ + .candidate-personality { + @apply transition-all duration-700; + } + + .recruiter-personality { + @apply transition-all duration-700; + } + + /* Subtle gradient backgrounds for visual distinction */ + .candidate-bg-subtle { + background: linear-gradient(135deg, + hsl(var(--candidate-bg-primary)) 0%, + hsl(var(--candidate-bg-secondary)) 100%); + } + + .recruiter-bg-subtle { + background: linear-gradient(135deg, + hsl(var(--recruiter-bg-primary)) 0%, + hsl(var(--recruiter-bg-secondary)) 100%); + } } .theme-transition { 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/guidance/access/route.ts b/app/api/user/guidance/access/route.ts new file mode 100644 index 0000000..b1b3219 --- /dev/null +++ b/app/api/user/guidance/access/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import prisma from "@/prisma/prisma"; + +export async function POST() { + try { + const session = await auth(); + + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Update the user's lastGuidanceAccess timestamp + const updatedUser = await prisma.user.update({ + where: { email: session.user.email }, + data: { + lastGuidanceAccess: new Date(), + }, + }); + + return NextResponse.json({ + success: true, + lastGuidanceAccess: updatedUser.lastGuidanceAccess, + }); + } catch (error) { + console.error("Error updating guidance access:", error); + return NextResponse.json( + { error: "Failed to update guidance access" }, + { status: 500 }, + ); + } +} diff --git a/app/api/user/guidance/preferences/route.ts b/app/api/user/guidance/preferences/route.ts new file mode 100644 index 0000000..32e6029 --- /dev/null +++ b/app/api/user/guidance/preferences/route.ts @@ -0,0 +1,94 @@ +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( + { success: false, error: "Unauthorized" }, + { status: 401 }, + ); + } + + const preferences = await request.json(); + + // Validate the preferences structure + const validPreferences = { + experienceLevel: preferences.experienceLevel || null, + learningStyle: preferences.learningStyle || null, + pacePreference: preferences.pacePreference || null, + timeCommitment: preferences.timeCommitment || null, + priorityTopics: Array.isArray(preferences.priorityTopics) + ? preferences.priorityTopics + : [], + currentChallenges: Array.isArray(preferences.currentChallenges) + ? preferences.currentChallenges + : [], + primaryGoals: Array.isArray(preferences.primaryGoals) + ? preferences.primaryGoals + : [], + preferredContentType: Array.isArray(preferences.preferredContentType) + ? preferences.preferredContentType + : [], + }; + + // Update user's guidance preferences + await prisma.user.update({ + where: { + id: session.user.id, + }, + data: { + guidancePreferences: validPreferences, + }, + }); + + return NextResponse.json({ + success: true, + message: "Guidance preferences updated successfully", + preferences: validPreferences, + }); + } catch (error) { + console.error("Error updating guidance preferences:", error); + return NextResponse.json( + { success: false, error: "Failed to update preferences" }, + { status: 500 }, + ); + } +} + +export async function GET() { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 }, + ); + } + + const user = await prisma.user.findUnique({ + where: { + id: session.user.id, + }, + select: { + guidancePreferences: true, + }, + }); + + return NextResponse.json({ + success: true, + preferences: user?.guidancePreferences || {}, + }); + } catch (error) { + console.error("Error fetching guidance preferences:", error); + return NextResponse.json( + { success: false, error: "Failed to fetch preferences" }, + { status: 500 }, + ); + } +} diff --git a/app/api/user/onboarding/route.ts b/app/api/user/onboarding/route.ts new file mode 100644 index 0000000..643bd53 --- /dev/null +++ b/app/api/user/onboarding/route.ts @@ -0,0 +1,61 @@ +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?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { + userType, + field, + specializations, + careerStage, + newsletterSubscribed, + onboardingCompleted, + guidancePreferences, + referralSource, + } = body; + + const updatedUser = await prisma.user.update({ + where: { email: session.user.email }, + data: { + userType, + field, + specializations, + careerStage, + newsletterSubscribed, + onboardingCompleted, + referralSource, + guidancePreferences: { + field, + specializations, + careerStage, + lastUpdated: new Date().toISOString(), + ...guidancePreferences, + }, + }, + }); + + return NextResponse.json({ + success: true, + user: { + userType: updatedUser.userType, + field: updatedUser.field, + onboardingCompleted: updatedUser.onboardingCompleted, + }, + }); + } catch (error) { + console.error("Error updating user onboarding:", error); + return NextResponse.json( + { error: "Failed to update user preferences" }, + { status: 500 }, + ); + } +} diff --git a/app/api/user/profile/route.ts b/app/api/user/profile/route.ts new file mode 100644 index 0000000..b643126 --- /dev/null +++ b/app/api/user/profile/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import prisma from "@/prisma/prisma"; + +export async function GET() { + try { + const session = await auth(); + + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { + id: true, + email: true, + userType: true, + field: true, + specializations: true, + careerStage: true, + onboardingCompleted: true, + isTestAccount: true, + newsletterSubscribed: true, + lastGuidanceAccess: true, + }, + }); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json({ user }); + } catch (error) { + console.error("Error fetching user profile:", error); + return NextResponse.json( + { error: "Failed to fetch user profile" }, + { 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..71bd0b0 --- /dev/null +++ b/app/api/user/switch-role/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import prisma from "@/prisma/prisma"; +import { isCandidate } from "@/lib/roleUtils"; + +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: isCandidate({ userType }) ? "/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..c0b0666 --- /dev/null +++ b/app/api/user/update-type/route.ts @@ -0,0 +1,38 @@ +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 (don't mark onboarding as completed yet) + await prisma.user.update({ + where: { id: session.user.id }, + data: { + userType, + // Will be set to true after full enhanced onboarding + onboardingCompleted: false, + }, + }); + + 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..6951e33 --- /dev/null +++ b/app/app/_components/AnalyzeContent.tsx @@ -0,0 +1,506 @@ +"use client"; + +import type React from "react"; +import { useState } from "react"; + +import { useRouter } from "next/navigation"; + +import { motion } from "framer-motion"; +import { + ArrowUpIcon, + BarChart3, + FileIcon, + FileText, + FileWarningIcon, + Target, + TrendingUp, + XCircleIcon, +} 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 { + 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 { fadeUpAnimation } from "@/lib/animations"; +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 +

+
+ + +
+ +
+ {isDragActive ? ( + + ) : ( + + )} + + {isDragActive ? ( +

+ Drop your CV here +

+ ) : ( +
+

+ Click to upload + + {" "} + or drag and drop + +

+

+ PDF files only, up to 10MB +

+
+ )} + + {extractError && ( +
+ +

PDF Files Only

+
+ )} +
+ + {extractError && ( +
+
+ +

{extractError}

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

+ {selectedFile.name} +

+

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

+
+
+ +
+
+ )} +
+
+ + {selectedFile && ( + + + +

+ 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 + + + +
+ +