Initial commit
This commit is contained in:
		
							
								
								
									
										8
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | node_modules/ | ||||||
|  | dist/ | ||||||
|  | .env | ||||||
|  | .env.* | ||||||
|  | *.log | ||||||
|  | tmp/ | ||||||
|  | .DS_Store | ||||||
|  | .vscode/ | ||||||
							
								
								
									
										241
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,241 @@ | |||||||
|  | # MysteryApp-Cursor - Recruiter Workflow SAAS | ||||||
|  |  | ||||||
|  | A comprehensive SAAS application for managing recruiter workflows, built with modern technologies and following TDD principles. | ||||||
|  |  | ||||||
|  | ## Features | ||||||
|  |  | ||||||
|  | ### Core Functionality | ||||||
|  | - **User Management**: Multi-role authentication system (Admin, Recruiter, Employer, Candidate) | ||||||
|  | - **Employer Management**: Company profiles, job postings, candidate management | ||||||
|  | - **Candidate Management**: Profile creation, resume uploads, application tracking | ||||||
|  | - **Job Management**: Job posting creation, filtering, search capabilities | ||||||
|  | - **Application Tracking**: End-to-end application workflow management | ||||||
|  | - **Resume Management**: File upload, download, and management system | ||||||
|  |  | ||||||
|  | ### User Roles | ||||||
|  | - **Admin**: Full system access and user management | ||||||
|  | - **Recruiter**: Candidate and employer management, job posting oversight | ||||||
|  | - **Employer**: Job posting creation, candidate review, application management | ||||||
|  | - **Candidate**: Job browsing, application submission, profile management | ||||||
|  |  | ||||||
|  | ## Technology Stack | ||||||
|  |  | ||||||
|  | ### Backend | ||||||
|  | - **Node.js** with Express.js | ||||||
|  | - **PostgreSQL** database | ||||||
|  | - **JWT** authentication | ||||||
|  | - **Multer** for file uploads | ||||||
|  | - **Jest** for testing | ||||||
|  | - **Docker** containerization | ||||||
|  |  | ||||||
|  | ### Frontend | ||||||
|  | - **React 18** with modern hooks | ||||||
|  | - **React Router** for navigation | ||||||
|  | - **React Query** for data fetching | ||||||
|  | - **Tailwind CSS** for styling | ||||||
|  | - **Lucide React** for icons | ||||||
|  | - **React Hot Toast** for notifications | ||||||
|  |  | ||||||
|  | ### Infrastructure | ||||||
|  | - **Docker Compose** for orchestration | ||||||
|  | - **PostgreSQL** database | ||||||
|  | - **File upload** handling with proper validation | ||||||
|  |  | ||||||
|  | ## Getting Started | ||||||
|  |  | ||||||
|  | ### Prerequisites | ||||||
|  | - Docker and Docker Compose | ||||||
|  | - Git | ||||||
|  |  | ||||||
|  | ### Installation | ||||||
|  |  | ||||||
|  | 1. **Clone the repository** | ||||||
|  |    ```bash | ||||||
|  |    git clone <repository-url> | ||||||
|  |    cd MysteryApp-Cursor | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 2. **Start the application** | ||||||
|  |    ```bash | ||||||
|  |    docker-compose up --build | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 3. **Initialize the database** | ||||||
|  |    ```bash | ||||||
|  |    # Run database migrations | ||||||
|  |    docker-compose exec MysteryApp-Cursor-backend npm run migrate | ||||||
|  |     | ||||||
|  |    # Seed the database with sample data | ||||||
|  |    docker-compose exec MysteryApp-Cursor-backend npm run seed | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 4. **Access the application** | ||||||
|  |    - Frontend: http://localhost:3000 | ||||||
|  |    - Backend API: http://localhost:3001 | ||||||
|  |    - Database: localhost:5432 | ||||||
|  |  | ||||||
|  | ### Demo Accounts | ||||||
|  |  | ||||||
|  | The application comes with pre-seeded demo accounts: | ||||||
|  |  | ||||||
|  | - **Admin**: admin@mysteryapp.com / admin123 | ||||||
|  | - **Recruiter**: recruiter@mysteryapp.com / recruiter123 | ||||||
|  | - **Employer**: employer@techcorp.com / employer123 | ||||||
|  | - **Candidate**: candidate@example.com / candidate123 | ||||||
|  |  | ||||||
|  | ## API Documentation | ||||||
|  |  | ||||||
|  | ### Authentication Endpoints | ||||||
|  | - `POST /api/auth/register` - User registration | ||||||
|  | - `POST /api/auth/login` - User login | ||||||
|  | - `GET /api/auth/me` - Get current user | ||||||
|  | - `POST /api/auth/logout` - User logout | ||||||
|  |  | ||||||
|  | ### User Management | ||||||
|  | - `GET /api/users` - List all users (Admin only) | ||||||
|  | - `GET /api/users/:id` - Get user by ID | ||||||
|  | - `PUT /api/users/:id` - Update user profile | ||||||
|  | - `PUT /api/users/:id/deactivate` - Deactivate user (Admin only) | ||||||
|  |  | ||||||
|  | ### Job Management | ||||||
|  | - `GET /api/jobs` - List jobs with filtering | ||||||
|  | - `GET /api/jobs/:id` - Get job details | ||||||
|  | - `POST /api/jobs` - Create job posting | ||||||
|  | - `PUT /api/jobs/:id` - Update job posting | ||||||
|  | - `DELETE /api/jobs/:id` - Delete job posting | ||||||
|  |  | ||||||
|  | ### Candidate Management | ||||||
|  | - `GET /api/candidates` - List candidates with filtering | ||||||
|  | - `GET /api/candidates/:id` - Get candidate details | ||||||
|  | - `POST /api/candidates` - Create candidate profile | ||||||
|  | - `PUT /api/candidates/:id` - Update candidate profile | ||||||
|  |  | ||||||
|  | ### Application Management | ||||||
|  | - `GET /api/applications` - List applications | ||||||
|  | - `GET /api/applications/:id` - Get application details | ||||||
|  | - `POST /api/applications` - Submit application | ||||||
|  | - `PUT /api/applications/:id/status` - Update application status | ||||||
|  |  | ||||||
|  | ### Resume Management | ||||||
|  | - `GET /api/resumes/candidate/:candidateId` - Get candidate resumes | ||||||
|  | - `POST /api/resumes/upload` - Upload resume | ||||||
|  | - `GET /api/resumes/:id/download` - Download resume | ||||||
|  | - `PUT /api/resumes/:id/primary` - Set primary resume | ||||||
|  | - `DELETE /api/resumes/:id` - Delete resume | ||||||
|  |  | ||||||
|  | ## Testing | ||||||
|  |  | ||||||
|  | ### Backend Tests | ||||||
|  | ```bash | ||||||
|  | # Run all tests | ||||||
|  | docker-compose exec MysteryApp-Cursor-backend npm test | ||||||
|  |  | ||||||
|  | # Run tests in watch mode | ||||||
|  | docker-compose exec MysteryApp-Cursor-backend npm run test:watch | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Frontend Tests | ||||||
|  | ```bash | ||||||
|  | # Run frontend tests | ||||||
|  | docker-compose exec MysteryApp-Cursor-frontend npm test | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Development | ||||||
|  |  | ||||||
|  | ### Project Structure | ||||||
|  | ``` | ||||||
|  | MysteryApp-Cursor/ | ||||||
|  | ├── backend/ | ||||||
|  | │   ├── src/ | ||||||
|  | │   │   ├── routes/          # API route handlers | ||||||
|  | │   │   ├── middleware/     # Authentication middleware | ||||||
|  | │   │   ├── database/       # Database schema and migrations | ||||||
|  | │   │   ├── tests/          # Backend tests | ||||||
|  | │   │   └── server.js       # Main server file | ||||||
|  | │   ├── uploads/            # File upload directory | ||||||
|  | │   └── Dockerfile | ||||||
|  | ├── frontend/ | ||||||
|  | │   ├── src/ | ||||||
|  | │   │   ├── components/     # Reusable React components | ||||||
|  | │   │   ├── pages/          # Page components | ||||||
|  | │   │   ├── contexts/       # React contexts | ||||||
|  | │   │   └── App.js          # Main App component | ||||||
|  | │   └── Dockerfile | ||||||
|  | ├── docker-compose.yml      # Docker orchestration | ||||||
|  | └── README.md | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Database Schema | ||||||
|  |  | ||||||
|  | The application uses a comprehensive PostgreSQL schema with the following main tables: | ||||||
|  | - `users` - User authentication and basic info | ||||||
|  | - `employers` - Company/employer profiles | ||||||
|  | - `candidates` - Candidate profiles and skills | ||||||
|  | - `jobs` - Job postings with requirements | ||||||
|  | - `applications` - Job applications and status tracking | ||||||
|  | - `resumes` - Resume file management | ||||||
|  | - `interviews` - Interview scheduling and management | ||||||
|  |  | ||||||
|  | ## Features in Detail | ||||||
|  |  | ||||||
|  | ### Job Management | ||||||
|  | - Advanced filtering by location, salary, experience level, skills | ||||||
|  | - Search functionality across job titles and descriptions | ||||||
|  | - Remote work support | ||||||
|  | - Application deadline management | ||||||
|  | - Status tracking (active, paused, closed, draft) | ||||||
|  |  | ||||||
|  | ### Candidate Management | ||||||
|  | - Skills-based filtering and search | ||||||
|  | - Experience level categorization | ||||||
|  | - Location-based filtering | ||||||
|  | - Salary expectation tracking | ||||||
|  | - Portfolio and social media links | ||||||
|  |  | ||||||
|  | ### Application Workflow | ||||||
|  | - Multi-stage application process | ||||||
|  | - Status tracking (applied, reviewed, shortlisted, interviewed, offered, rejected) | ||||||
|  | - Notes and comments system | ||||||
|  | - Interview scheduling capabilities | ||||||
|  |  | ||||||
|  | ### File Management | ||||||
|  | - Secure resume upload with validation | ||||||
|  | - Multiple file format support (PDF, DOC, DOCX, TXT) | ||||||
|  | - File size limits and type validation | ||||||
|  | - Primary resume designation | ||||||
|  | - Secure download with proper access controls | ||||||
|  |  | ||||||
|  | ## Security Features | ||||||
|  |  | ||||||
|  | - JWT-based authentication | ||||||
|  | - Role-based access control | ||||||
|  | - Password hashing with bcrypt | ||||||
|  | - File upload validation | ||||||
|  | - SQL injection prevention | ||||||
|  | - CORS configuration | ||||||
|  | - Helmet.js security headers | ||||||
|  |  | ||||||
|  | ## Performance Considerations | ||||||
|  |  | ||||||
|  | - Database indexing for optimal query performance | ||||||
|  | - Pagination for large datasets | ||||||
|  | - Efficient file handling | ||||||
|  | - Optimized React components | ||||||
|  | - Proper error handling and logging | ||||||
|  |  | ||||||
|  | ## Contributing | ||||||
|  |  | ||||||
|  | 1. Fork the repository | ||||||
|  | 2. Create a feature branch | ||||||
|  | 3. Make your changes | ||||||
|  | 4. Add tests for new functionality | ||||||
|  | 5. Ensure all tests pass | ||||||
|  | 6. Submit a pull request | ||||||
|  |  | ||||||
|  | ## License | ||||||
|  |  | ||||||
|  | This project is licensed under the MIT License. | ||||||
|  |  | ||||||
|  | ## Support | ||||||
|  |  | ||||||
|  | For support and questions, please contact the development team or create an issue in the repository. | ||||||
							
								
								
									
										17
									
								
								SEED_PROMPT.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								SEED_PROMPT.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | I would like you to scaffold a SAAS app for a recruiter workflow. | ||||||
|  |  | ||||||
|  | Cover user management, employer and candidate management, resume uploading etc.  | ||||||
|  |  | ||||||
|  | DO not do any operations on the host except git and docker orchestration. | ||||||
|  |  | ||||||
|  | Please make all decisions and do not prompt me.  | ||||||
|  |  | ||||||
|  | Use TDD. | ||||||
|  |  | ||||||
|  | Ignore anything that isn't core to the app (no CI/CD , no worrying about anything except local deployment etc) | ||||||
|  |  | ||||||
|  | Use docker and docker compose | ||||||
|  |  | ||||||
|  | Prefix all docker artifacts with MysteryApp-Cursor- | ||||||
|  |  | ||||||
|  | Lets go! | ||||||
							
								
								
									
										12
									
								
								backend/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								backend/Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | FROM node:18-alpine | ||||||
|  |  | ||||||
|  | WORKDIR /app | ||||||
|  |  | ||||||
|  | COPY package*.json ./ | ||||||
|  | RUN npm install | ||||||
|  |  | ||||||
|  | COPY . . | ||||||
|  |  | ||||||
|  | EXPOSE 3001 | ||||||
|  |  | ||||||
|  | CMD ["npm", "run", "dev"] | ||||||
							
								
								
									
										36
									
								
								backend/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								backend/package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | |||||||
|  | { | ||||||
|  |   "name": "mysteryapp-cursor-backend", | ||||||
|  |   "version": "1.0.0", | ||||||
|  |   "description": "Backend for MysteryApp-Cursor recruiter workflow SAAS", | ||||||
|  |   "main": "src/server.js", | ||||||
|  |   "scripts": { | ||||||
|  |     "start": "node src/server.js", | ||||||
|  |     "dev": "nodemon src/server.js", | ||||||
|  |     "test": "jest", | ||||||
|  |     "test:watch": "jest --watch", | ||||||
|  |     "migrate": "node src/database/migrate.js", | ||||||
|  |     "seed": "node src/database/seed.js" | ||||||
|  |   }, | ||||||
|  |   "dependencies": { | ||||||
|  |     "express": "^4.18.2", | ||||||
|  |     "cors": "^2.8.5", | ||||||
|  |     "helmet": "^7.1.0", | ||||||
|  |     "bcryptjs": "^2.4.3", | ||||||
|  |     "jsonwebtoken": "^9.0.2", | ||||||
|  |     "pg": "^8.11.3", | ||||||
|  |     "multer": "^1.4.5-lts.1", | ||||||
|  |     "express-validator": "^7.0.1", | ||||||
|  |     "dotenv": "^16.3.1", | ||||||
|  |     "uuid": "^9.0.1", | ||||||
|  |     "morgan": "^1.10.0" | ||||||
|  |   }, | ||||||
|  |   "devDependencies": { | ||||||
|  |     "nodemon": "^3.0.2", | ||||||
|  |     "jest": "^29.7.0", | ||||||
|  |     "supertest": "^6.3.3", | ||||||
|  |     "@types/jest": "^29.5.8" | ||||||
|  |   }, | ||||||
|  |   "keywords": ["recruiter", "saas", "workflow", "hiring"], | ||||||
|  |   "author": "MysteryApp-Cursor", | ||||||
|  |   "license": "MIT" | ||||||
|  | } | ||||||
							
								
								
									
										18
									
								
								backend/src/database/connection.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								backend/src/database/connection.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | const { Pool } = require('pg'); | ||||||
|  | require('dotenv').config(); | ||||||
|  |  | ||||||
|  | const pool = new Pool({ | ||||||
|  |   connectionString: process.env.DATABASE_URL, | ||||||
|  |   ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Test database connection | ||||||
|  | pool.on('connect', () => { | ||||||
|  |   console.log('Connected to MysteryApp-Cursor database'); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | pool.on('error', (err) => { | ||||||
|  |   console.error('Database connection error:', err); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | module.exports = pool; | ||||||
							
								
								
									
										29
									
								
								backend/src/database/migrate.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								backend/src/database/migrate.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | |||||||
|  | const fs = require('fs'); | ||||||
|  | const path = require('path'); | ||||||
|  | const pool = require('./connection'); | ||||||
|  |  | ||||||
|  | async function migrate() { | ||||||
|  |   try { | ||||||
|  |     console.log('Starting database migration...'); | ||||||
|  |      | ||||||
|  |     // Read schema file | ||||||
|  |     const schemaPath = path.join(__dirname, 'schema.sql'); | ||||||
|  |     const schema = fs.readFileSync(schemaPath, 'utf8'); | ||||||
|  |      | ||||||
|  |     // Execute schema | ||||||
|  |     await pool.query(schema); | ||||||
|  |      | ||||||
|  |     console.log('Database migration completed successfully!'); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Migration failed:', error); | ||||||
|  |     process.exit(1); | ||||||
|  |   } finally { | ||||||
|  |     await pool.end(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | if (require.main === module) { | ||||||
|  |   migrate(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | module.exports = migrate; | ||||||
							
								
								
									
										141
									
								
								backend/src/database/schema.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								backend/src/database/schema.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,141 @@ | |||||||
|  | -- MysteryApp-Cursor Database Schema | ||||||
|  |  | ||||||
|  | -- Users table (for authentication and user management) | ||||||
|  | CREATE TABLE IF NOT EXISTS users ( | ||||||
|  |     id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||||||
|  |     email VARCHAR(255) UNIQUE NOT NULL, | ||||||
|  |     password_hash VARCHAR(255) NOT NULL, | ||||||
|  |     first_name VARCHAR(100) NOT NULL, | ||||||
|  |     last_name VARCHAR(100) NOT NULL, | ||||||
|  |     role VARCHAR(50) NOT NULL CHECK (role IN ('admin', 'recruiter', 'employer', 'candidate')), | ||||||
|  |     is_active BOOLEAN DEFAULT true, | ||||||
|  |     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | -- Employers table | ||||||
|  | CREATE TABLE IF NOT EXISTS employers ( | ||||||
|  |     id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||||||
|  |     user_id UUID REFERENCES users(id) ON DELETE CASCADE, | ||||||
|  |     company_name VARCHAR(255) NOT NULL, | ||||||
|  |     industry VARCHAR(100), | ||||||
|  |     company_size VARCHAR(50), | ||||||
|  |     website VARCHAR(255), | ||||||
|  |     description TEXT, | ||||||
|  |     address TEXT, | ||||||
|  |     phone VARCHAR(20), | ||||||
|  |     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | -- Candidates table | ||||||
|  | CREATE TABLE IF NOT EXISTS candidates ( | ||||||
|  |     id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||||||
|  |     user_id UUID REFERENCES users(id) ON DELETE CASCADE, | ||||||
|  |     phone VARCHAR(20), | ||||||
|  |     location VARCHAR(255), | ||||||
|  |     linkedin_url VARCHAR(255), | ||||||
|  |     github_url VARCHAR(255), | ||||||
|  |     portfolio_url VARCHAR(255), | ||||||
|  |     bio TEXT, | ||||||
|  |     skills TEXT[], | ||||||
|  |     experience_level VARCHAR(50), | ||||||
|  |     availability VARCHAR(50), | ||||||
|  |     salary_expectation INTEGER, | ||||||
|  |     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | -- Job postings table | ||||||
|  | CREATE TABLE IF NOT EXISTS jobs ( | ||||||
|  |     id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||||||
|  |     employer_id UUID REFERENCES employers(id) ON DELETE CASCADE, | ||||||
|  |     title VARCHAR(255) NOT NULL, | ||||||
|  |     description TEXT NOT NULL, | ||||||
|  |     requirements TEXT[], | ||||||
|  |     responsibilities TEXT[], | ||||||
|  |     location VARCHAR(255), | ||||||
|  |     employment_type VARCHAR(50) CHECK (employment_type IN ('full-time', 'part-time', 'contract', 'internship')), | ||||||
|  |     salary_min INTEGER, | ||||||
|  |     salary_max INTEGER, | ||||||
|  |     currency VARCHAR(3) DEFAULT 'USD', | ||||||
|  |     status VARCHAR(50) DEFAULT 'active' CHECK (status IN ('active', 'paused', 'closed', 'draft')), | ||||||
|  |     remote_allowed BOOLEAN DEFAULT false, | ||||||
|  |     experience_level VARCHAR(50), | ||||||
|  |     skills_required TEXT[], | ||||||
|  |     benefits TEXT[], | ||||||
|  |     application_deadline DATE, | ||||||
|  |     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | -- Applications table | ||||||
|  | CREATE TABLE IF NOT EXISTS applications ( | ||||||
|  |     id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||||||
|  |     job_id UUID REFERENCES jobs(id) ON DELETE CASCADE, | ||||||
|  |     candidate_id UUID REFERENCES candidates(id) ON DELETE CASCADE, | ||||||
|  |     status VARCHAR(50) DEFAULT 'applied' CHECK (status IN ('applied', 'reviewed', 'shortlisted', 'interviewed', 'offered', 'rejected', 'withdrawn')), | ||||||
|  |     cover_letter TEXT, | ||||||
|  |     notes TEXT, | ||||||
|  |     applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |     UNIQUE(job_id, candidate_id) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | -- Resumes table | ||||||
|  | CREATE TABLE IF NOT EXISTS resumes ( | ||||||
|  |     id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||||||
|  |     candidate_id UUID REFERENCES candidates(id) ON DELETE CASCADE, | ||||||
|  |     filename VARCHAR(255) NOT NULL, | ||||||
|  |     original_name VARCHAR(255) NOT NULL, | ||||||
|  |     file_path VARCHAR(500) NOT NULL, | ||||||
|  |     file_size INTEGER NOT NULL, | ||||||
|  |     mime_type VARCHAR(100) NOT NULL, | ||||||
|  |     is_primary BOOLEAN DEFAULT false, | ||||||
|  |     uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | -- Interviews table | ||||||
|  | CREATE TABLE IF NOT EXISTS interviews ( | ||||||
|  |     id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||||||
|  |     application_id UUID REFERENCES applications(id) ON DELETE CASCADE, | ||||||
|  |     scheduled_at TIMESTAMP NOT NULL, | ||||||
|  |     duration_minutes INTEGER DEFAULT 60, | ||||||
|  |     interview_type VARCHAR(50) CHECK (interview_type IN ('phone', 'video', 'in-person', 'technical')), | ||||||
|  |     location VARCHAR(255), | ||||||
|  |     meeting_link VARCHAR(500), | ||||||
|  |     notes TEXT, | ||||||
|  |     status VARCHAR(50) DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'completed', 'cancelled', 'rescheduled')), | ||||||
|  |     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | -- Create indexes for better performance | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_employers_user_id ON employers(user_id); | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_candidates_user_id ON candidates(user_id); | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_jobs_employer_id ON jobs(employer_id); | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_applications_job_id ON applications(job_id); | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_applications_candidate_id ON applications(candidate_id); | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status); | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_resumes_candidate_id ON resumes(candidate_id); | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_interviews_application_id ON interviews(application_id); | ||||||
|  |  | ||||||
|  | -- Create updated_at trigger function | ||||||
|  | CREATE OR REPLACE FUNCTION update_updated_at_column() | ||||||
|  | RETURNS TRIGGER AS $$ | ||||||
|  | BEGIN | ||||||
|  |     NEW.updated_at = CURRENT_TIMESTAMP; | ||||||
|  |     RETURN NEW; | ||||||
|  | END; | ||||||
|  | $$ language 'plpgsql'; | ||||||
|  |  | ||||||
|  | -- Apply updated_at triggers | ||||||
|  | CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); | ||||||
|  | CREATE TRIGGER update_employers_updated_at BEFORE UPDATE ON employers FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); | ||||||
|  | CREATE TRIGGER update_candidates_updated_at BEFORE UPDATE ON candidates FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); | ||||||
|  | CREATE TRIGGER update_jobs_updated_at BEFORE UPDATE ON jobs FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); | ||||||
|  | CREATE TRIGGER update_applications_updated_at BEFORE UPDATE ON applications FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); | ||||||
|  | CREATE TRIGGER update_interviews_updated_at BEFORE UPDATE ON interviews FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); | ||||||
							
								
								
									
										128
									
								
								backend/src/database/seed.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								backend/src/database/seed.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,128 @@ | |||||||
|  | const bcrypt = require('bcryptjs'); | ||||||
|  | const pool = require('./connection'); | ||||||
|  |  | ||||||
|  | async function seed() { | ||||||
|  |   try { | ||||||
|  |     console.log('Starting database seeding...'); | ||||||
|  |      | ||||||
|  |     // Hash passwords | ||||||
|  |     const adminPassword = await bcrypt.hash('admin123', 10); | ||||||
|  |     const recruiterPassword = await bcrypt.hash('recruiter123', 10); | ||||||
|  |     const employerPassword = await bcrypt.hash('employer123', 10); | ||||||
|  |     const candidatePassword = await bcrypt.hash('candidate123', 10); | ||||||
|  |      | ||||||
|  |     // Insert users | ||||||
|  |     const users = [ | ||||||
|  |       { | ||||||
|  |         email: 'admin@mysteryapp.com', | ||||||
|  |         password_hash: adminPassword, | ||||||
|  |         first_name: 'Admin', | ||||||
|  |         last_name: 'User', | ||||||
|  |         role: 'admin' | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         email: 'recruiter@mysteryapp.com', | ||||||
|  |         password_hash: recruiterPassword, | ||||||
|  |         first_name: 'John', | ||||||
|  |         last_name: 'Recruiter', | ||||||
|  |         role: 'recruiter' | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         email: 'employer@techcorp.com', | ||||||
|  |         password_hash: employerPassword, | ||||||
|  |         first_name: 'Jane', | ||||||
|  |         last_name: 'Smith', | ||||||
|  |         role: 'employer' | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         email: 'candidate@example.com', | ||||||
|  |         password_hash: candidatePassword, | ||||||
|  |         first_name: 'Mike', | ||||||
|  |         last_name: 'Johnson', | ||||||
|  |         role: 'candidate' | ||||||
|  |       } | ||||||
|  |     ]; | ||||||
|  |      | ||||||
|  |     const userResults = []; | ||||||
|  |     for (const user of users) { | ||||||
|  |       const result = await pool.query( | ||||||
|  |         'INSERT INTO users (email, password_hash, first_name, last_name, role) VALUES ($1, $2, $3, $4, $5) RETURNING id', | ||||||
|  |         [user.email, user.password_hash, user.first_name, user.last_name, user.role] | ||||||
|  |       ); | ||||||
|  |       userResults.push({ ...user, id: result.rows[0].id }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // Insert employer | ||||||
|  |     const employerResult = await pool.query( | ||||||
|  |       `INSERT INTO employers (user_id, company_name, industry, company_size, website, description, address, phone) | ||||||
|  |        VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, | ||||||
|  |       [ | ||||||
|  |         userResults[2].id, // employer user | ||||||
|  |         'TechCorp Solutions', | ||||||
|  |         'Technology', | ||||||
|  |         '50-200', | ||||||
|  |         'https://techcorp.com', | ||||||
|  |         'Leading technology company specializing in innovative software solutions.', | ||||||
|  |         '123 Tech Street, San Francisco, CA 94105', | ||||||
|  |         '+1-555-0123' | ||||||
|  |       ] | ||||||
|  |     ); | ||||||
|  |      | ||||||
|  |     // Insert candidate | ||||||
|  |     await pool.query( | ||||||
|  |       `INSERT INTO candidates (user_id, phone, location, linkedin_url, bio, skills, experience_level, availability, salary_expectation) | ||||||
|  |        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, | ||||||
|  |       [ | ||||||
|  |         userResults[3].id, // candidate user | ||||||
|  |         '+1-555-0456', | ||||||
|  |         'San Francisco, CA', | ||||||
|  |         'https://linkedin.com/in/mikejohnson', | ||||||
|  |         'Experienced software developer with 5+ years in full-stack development.', | ||||||
|  |         ['JavaScript', 'React', 'Node.js', 'Python', 'PostgreSQL'], | ||||||
|  |         'senior', | ||||||
|  |         'immediately', | ||||||
|  |         120000 | ||||||
|  |       ] | ||||||
|  |     ); | ||||||
|  |      | ||||||
|  |     // Insert job posting | ||||||
|  |     const jobResult = await pool.query( | ||||||
|  |       `INSERT INTO jobs (employer_id, title, description, requirements, responsibilities, location, employment_type, salary_min, salary_max, remote_allowed, experience_level, skills_required, benefits) | ||||||
|  |        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`, | ||||||
|  |       [ | ||||||
|  |         employerResult.rows[0].id, | ||||||
|  |         'Senior Full Stack Developer', | ||||||
|  |         'We are looking for a talented Senior Full Stack Developer to join our growing team.', | ||||||
|  |         ['5+ years of experience', 'Bachelor degree in Computer Science', 'Strong problem-solving skills'], | ||||||
|  |         ['Develop web applications', 'Collaborate with team members', 'Code reviews', 'Mentor junior developers'], | ||||||
|  |         'San Francisco, CA', | ||||||
|  |         'full-time', | ||||||
|  |         100000, | ||||||
|  |         150000, | ||||||
|  |         true, | ||||||
|  |         'senior', | ||||||
|  |         ['JavaScript', 'React', 'Node.js', 'PostgreSQL', 'AWS'], | ||||||
|  |         ['Health insurance', '401k', 'Flexible work hours', 'Remote work'] | ||||||
|  |       ] | ||||||
|  |     ); | ||||||
|  |      | ||||||
|  |     console.log('Database seeding completed successfully!'); | ||||||
|  |     console.log('Sample users created:'); | ||||||
|  |     console.log('- Admin: admin@mysteryapp.com / admin123'); | ||||||
|  |     console.log('- Recruiter: recruiter@mysteryapp.com / recruiter123'); | ||||||
|  |     console.log('- Employer: employer@techcorp.com / employer123'); | ||||||
|  |     console.log('- Candidate: candidate@example.com / candidate123'); | ||||||
|  |      | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Seeding failed:', error); | ||||||
|  |     process.exit(1); | ||||||
|  |   } finally { | ||||||
|  |     await pool.end(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | if (require.main === module) { | ||||||
|  |   seed(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | module.exports = seed; | ||||||
							
								
								
									
										54
									
								
								backend/src/middleware/auth.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								backend/src/middleware/auth.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | |||||||
|  | const jwt = require('jsonwebtoken'); | ||||||
|  | const pool = require('../database/connection'); | ||||||
|  |  | ||||||
|  | const authenticateToken = async (req, res, next) => { | ||||||
|  |   const authHeader = req.headers['authorization']; | ||||||
|  |   const token = authHeader && authHeader.split(' ')[1]; | ||||||
|  |  | ||||||
|  |   if (!token) { | ||||||
|  |     return res.status(401).json({ error: 'Access token required' }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     const decoded = jwt.verify(token, process.env.JWT_SECRET); | ||||||
|  |      | ||||||
|  |     // Get user details from database | ||||||
|  |     const userResult = await pool.query( | ||||||
|  |       'SELECT id, email, first_name, last_name, role, is_active FROM users WHERE id = $1', | ||||||
|  |       [decoded.userId] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (userResult.rows.length === 0) { | ||||||
|  |       return res.status(401).json({ error: 'Invalid token' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const user = userResult.rows[0]; | ||||||
|  |     if (!user.is_active) { | ||||||
|  |       return res.status(401).json({ error: 'Account deactivated' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     req.user = user; | ||||||
|  |     next(); | ||||||
|  |   } catch (error) { | ||||||
|  |     return res.status(403).json({ error: 'Invalid or expired token' }); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const requireRole = (roles) => { | ||||||
|  |   return (req, res, next) => { | ||||||
|  |     if (!req.user) { | ||||||
|  |       return res.status(401).json({ error: 'Authentication required' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!roles.includes(req.user.role)) { | ||||||
|  |       return res.status(403).json({ error: 'Insufficient permissions' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     next(); | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | module.exports = { | ||||||
|  |   authenticateToken, | ||||||
|  |   requireRole | ||||||
|  | }; | ||||||
							
								
								
									
										424
									
								
								backend/src/routes/applications.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										424
									
								
								backend/src/routes/applications.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,424 @@ | |||||||
|  | const express = require('express'); | ||||||
|  | const { body, validationResult } = require('express-validator'); | ||||||
|  | const pool = require('../database/connection'); | ||||||
|  | const { authenticateToken, requireRole } = require('../middleware/auth'); | ||||||
|  |  | ||||||
|  | const router = express.Router(); | ||||||
|  |  | ||||||
|  | // Get all applications (with filtering) | ||||||
|  | router.get('/', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const {  | ||||||
|  |       jobId,  | ||||||
|  |       candidateId,  | ||||||
|  |       status,  | ||||||
|  |       employerId, | ||||||
|  |       page = 1,  | ||||||
|  |       limit = 10  | ||||||
|  |     } = req.query; | ||||||
|  |  | ||||||
|  |     let query = ` | ||||||
|  |       SELECT a.*,  | ||||||
|  |              j.title as job_title,  | ||||||
|  |              j.employer_id, | ||||||
|  |              e.company_name, | ||||||
|  |              c.user_id as candidate_user_id, | ||||||
|  |              u.first_name,  | ||||||
|  |              u.last_name,  | ||||||
|  |              u.email as candidate_email | ||||||
|  |       FROM applications a | ||||||
|  |       JOIN jobs j ON a.job_id = j.id | ||||||
|  |       JOIN employers e ON j.employer_id = e.id | ||||||
|  |       JOIN candidates c ON a.candidate_id = c.id | ||||||
|  |       JOIN users u ON c.user_id = u.id | ||||||
|  |     `; | ||||||
|  |     const queryParams = []; | ||||||
|  |     let paramCount = 0; | ||||||
|  |     const conditions = []; | ||||||
|  |  | ||||||
|  |     if (jobId) { | ||||||
|  |       paramCount++; | ||||||
|  |       conditions.push(`a.job_id = $${paramCount}`); | ||||||
|  |       queryParams.push(jobId); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (candidateId) { | ||||||
|  |       paramCount++; | ||||||
|  |       conditions.push(`a.candidate_id = $${paramCount}`); | ||||||
|  |       queryParams.push(candidateId); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (status) { | ||||||
|  |       paramCount++; | ||||||
|  |       conditions.push(`a.status = $${paramCount}`); | ||||||
|  |       queryParams.push(status); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (employerId) { | ||||||
|  |       paramCount++; | ||||||
|  |       conditions.push(`j.employer_id = $${paramCount}`); | ||||||
|  |       queryParams.push(employerId); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Role-based filtering | ||||||
|  |     if (req.user.role === 'candidate') { | ||||||
|  |       // Candidates can only see their own applications | ||||||
|  |       const candidateResult = await pool.query( | ||||||
|  |         'SELECT id FROM candidates WHERE user_id = $1', | ||||||
|  |         [req.user.id] | ||||||
|  |       ); | ||||||
|  |       if (candidateResult.rows.length > 0) { | ||||||
|  |         paramCount++; | ||||||
|  |         conditions.push(`a.candidate_id = $${paramCount}`); | ||||||
|  |         queryParams.push(candidateResult.rows[0].id); | ||||||
|  |       } | ||||||
|  |     } else if (req.user.role === 'employer') { | ||||||
|  |       // Employers can only see applications for their jobs | ||||||
|  |       const employerResult = await pool.query( | ||||||
|  |         'SELECT id FROM employers WHERE user_id = $1', | ||||||
|  |         [req.user.id] | ||||||
|  |       ); | ||||||
|  |       if (employerResult.rows.length > 0) { | ||||||
|  |         paramCount++; | ||||||
|  |         conditions.push(`j.employer_id = $${paramCount}`); | ||||||
|  |         queryParams.push(employerResult.rows[0].id); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (conditions.length > 0) { | ||||||
|  |       query += ` WHERE ${conditions.join(' AND ')}`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     query += ` ORDER BY a.applied_at DESC`; | ||||||
|  |  | ||||||
|  |     // Add pagination | ||||||
|  |     const offset = (page - 1) * limit; | ||||||
|  |     paramCount++; | ||||||
|  |     query += ` LIMIT $${paramCount}`; | ||||||
|  |     queryParams.push(limit); | ||||||
|  |     paramCount++; | ||||||
|  |     query += ` OFFSET $${paramCount}`; | ||||||
|  |     queryParams.push(offset); | ||||||
|  |  | ||||||
|  |     const result = await pool.query(query, queryParams); | ||||||
|  |  | ||||||
|  |     // Get total count for pagination | ||||||
|  |     let countQuery = ` | ||||||
|  |       SELECT COUNT(*)  | ||||||
|  |       FROM applications a | ||||||
|  |       JOIN jobs j ON a.job_id = j.id | ||||||
|  |       JOIN employers e ON j.employer_id = e.id | ||||||
|  |       JOIN candidates c ON a.candidate_id = c.id | ||||||
|  |     `; | ||||||
|  |     const countParams = []; | ||||||
|  |     let countParamCount = 0; | ||||||
|  |     const countConditions = []; | ||||||
|  |  | ||||||
|  |     if (jobId) { | ||||||
|  |       countParamCount++; | ||||||
|  |       countConditions.push(`a.job_id = $${countParamCount}`); | ||||||
|  |       countParams.push(jobId); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (candidateId) { | ||||||
|  |       countParamCount++; | ||||||
|  |       countConditions.push(`a.candidate_id = $${countParamCount}`); | ||||||
|  |       countParams.push(candidateId); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (status) { | ||||||
|  |       countParamCount++; | ||||||
|  |       countConditions.push(`a.status = $${countParamCount}`); | ||||||
|  |       countParams.push(status); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (employerId) { | ||||||
|  |       countParamCount++; | ||||||
|  |       countConditions.push(`j.employer_id = $${countParamCount}`); | ||||||
|  |       countParams.push(employerId); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Role-based filtering for count | ||||||
|  |     if (req.user.role === 'candidate') { | ||||||
|  |       const candidateResult = await pool.query( | ||||||
|  |         'SELECT id FROM candidates WHERE user_id = $1', | ||||||
|  |         [req.user.id] | ||||||
|  |       ); | ||||||
|  |       if (candidateResult.rows.length > 0) { | ||||||
|  |         countParamCount++; | ||||||
|  |         countConditions.push(`a.candidate_id = $${countParamCount}`); | ||||||
|  |         countParams.push(candidateResult.rows[0].id); | ||||||
|  |       } | ||||||
|  |     } else if (req.user.role === 'employer') { | ||||||
|  |       const employerResult = await pool.query( | ||||||
|  |         'SELECT id FROM employers WHERE user_id = $1', | ||||||
|  |         [req.user.id] | ||||||
|  |       ); | ||||||
|  |       if (employerResult.rows.length > 0) { | ||||||
|  |         countParamCount++; | ||||||
|  |         countConditions.push(`j.employer_id = $${countParamCount}`); | ||||||
|  |         countParams.push(employerResult.rows[0].id); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (countConditions.length > 0) { | ||||||
|  |       countQuery += ` WHERE ${countConditions.join(' AND ')}`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const countResult = await pool.query(countQuery, countParams); | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       applications: result.rows, | ||||||
|  |       pagination: { | ||||||
|  |         page: parseInt(page), | ||||||
|  |         limit: parseInt(limit), | ||||||
|  |         total: parseInt(countResult.rows[0].count), | ||||||
|  |         pages: Math.ceil(countResult.rows[0].count / limit) | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Get applications error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to fetch applications' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Get application by ID | ||||||
|  | router.get('/:id', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { id } = req.params; | ||||||
|  |      | ||||||
|  |     const result = await pool.query(` | ||||||
|  |       SELECT a.*,  | ||||||
|  |              j.title as job_title,  | ||||||
|  |              j.description as job_description, | ||||||
|  |              j.employer_id, | ||||||
|  |              e.company_name, | ||||||
|  |              c.user_id as candidate_user_id, | ||||||
|  |              u.first_name,  | ||||||
|  |              u.last_name,  | ||||||
|  |              u.email as candidate_email | ||||||
|  |       FROM applications a | ||||||
|  |       JOIN jobs j ON a.job_id = j.id | ||||||
|  |       JOIN employers e ON j.employer_id = e.id | ||||||
|  |       JOIN candidates c ON a.candidate_id = c.id | ||||||
|  |       JOIN users u ON c.user_id = u.id | ||||||
|  |       WHERE a.id = $1 | ||||||
|  |     `, [id]); | ||||||
|  |  | ||||||
|  |     if (result.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'Application not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const application = result.rows[0]; | ||||||
|  |  | ||||||
|  |     // Check permissions | ||||||
|  |     if (req.user.role === 'candidate') { | ||||||
|  |       const candidateResult = await pool.query( | ||||||
|  |         'SELECT id FROM candidates WHERE user_id = $1', | ||||||
|  |         [req.user.id] | ||||||
|  |       ); | ||||||
|  |       if (candidateResult.rows.length === 0 || application.candidate_id !== candidateResult.rows[0].id) { | ||||||
|  |         return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |       } | ||||||
|  |     } else if (req.user.role === 'employer') { | ||||||
|  |       const employerResult = await pool.query( | ||||||
|  |         'SELECT id FROM employers WHERE user_id = $1', | ||||||
|  |         [req.user.id] | ||||||
|  |       ); | ||||||
|  |       if (employerResult.rows.length === 0 || application.employer_id !== employerResult.rows[0].id) { | ||||||
|  |         return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     res.json(application); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Get application error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to fetch application' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Create application | ||||||
|  | router.post('/', authenticateToken, requireRole(['candidate']), [ | ||||||
|  |   body('jobId').isUUID(), | ||||||
|  |   body('coverLetter').optional().trim(), | ||||||
|  |   body('notes').optional().trim() | ||||||
|  | ], async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const errors = validationResult(req); | ||||||
|  |     if (!errors.isEmpty()) { | ||||||
|  |       return res.status(400).json({ errors: errors.array() }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const { jobId, coverLetter, notes } = req.body; | ||||||
|  |  | ||||||
|  |     // Get candidate ID for the current user | ||||||
|  |     const candidateResult = await pool.query( | ||||||
|  |       'SELECT id FROM candidates WHERE user_id = $1', | ||||||
|  |       [req.user.id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (candidateResult.rows.length === 0) { | ||||||
|  |       return res.status(400).json({ error: 'Candidate profile not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const candidateId = candidateResult.rows[0].id; | ||||||
|  |  | ||||||
|  |     // Check if job exists and is active | ||||||
|  |     const jobResult = await pool.query( | ||||||
|  |       'SELECT id, status FROM jobs WHERE id = $1', | ||||||
|  |       [jobId] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (jobResult.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'Job not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (jobResult.rows[0].status !== 'active') { | ||||||
|  |       return res.status(400).json({ error: 'Job is not accepting applications' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Check if application already exists | ||||||
|  |     const existingApplication = await pool.query( | ||||||
|  |       'SELECT id FROM applications WHERE job_id = $1 AND candidate_id = $2', | ||||||
|  |       [jobId, candidateId] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (existingApplication.rows.length > 0) { | ||||||
|  |       return res.status(400).json({ error: 'Application already exists' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const result = await pool.query(` | ||||||
|  |       INSERT INTO applications (job_id, candidate_id, cover_letter, notes) | ||||||
|  |       VALUES ($1, $2, $3, $4) | ||||||
|  |       RETURNING * | ||||||
|  |     `, [jobId, candidateId, coverLetter, notes]); | ||||||
|  |  | ||||||
|  |     res.status(201).json({ | ||||||
|  |       message: 'Application submitted successfully', | ||||||
|  |       application: result.rows[0] | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Create application error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to submit application' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Update application status | ||||||
|  | router.put('/:id/status', authenticateToken, [ | ||||||
|  |   body('status').isIn(['applied', 'reviewed', 'shortlisted', 'interviewed', 'offered', 'rejected', 'withdrawn']) | ||||||
|  | ], async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const errors = validationResult(req); | ||||||
|  |     if (!errors.isEmpty()) { | ||||||
|  |       return res.status(400).json({ errors: errors.array() }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const { id } = req.params; | ||||||
|  |     const { status } = req.body; | ||||||
|  |  | ||||||
|  |     // Check if application exists and user has permission | ||||||
|  |     const applicationResult = await pool.query(` | ||||||
|  |       SELECT a.*, j.employer_id, e.user_id as employer_user_id | ||||||
|  |       FROM applications a | ||||||
|  |       JOIN jobs j ON a.job_id = j.id | ||||||
|  |       JOIN employers e ON j.employer_id = e.id | ||||||
|  |       WHERE a.id = $1 | ||||||
|  |     `, [id]); | ||||||
|  |  | ||||||
|  |     if (applicationResult.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'Application not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const application = applicationResult.rows[0]; | ||||||
|  |  | ||||||
|  |     // Check permissions | ||||||
|  |     if (req.user.role === 'candidate') { | ||||||
|  |       const candidateResult = await pool.query( | ||||||
|  |         'SELECT id FROM candidates WHERE user_id = $1', | ||||||
|  |         [req.user.id] | ||||||
|  |       ); | ||||||
|  |       if (candidateResult.rows.length === 0 || application.candidate_id !== candidateResult.rows[0].id) { | ||||||
|  |         return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |       } | ||||||
|  |       // Candidates can only withdraw their applications | ||||||
|  |       if (status !== 'withdrawn') { | ||||||
|  |         return res.status(403).json({ error: 'Candidates can only withdraw applications' }); | ||||||
|  |       } | ||||||
|  |     } else if (req.user.role === 'employer') { | ||||||
|  |       if (application.employer_user_id !== req.user.id) { | ||||||
|  |         return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |       } | ||||||
|  |     } else if (req.user.role !== 'admin' && req.user.role !== 'recruiter') { | ||||||
|  |       return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const result = await pool.query( | ||||||
|  |       'UPDATE applications SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *', | ||||||
|  |       [status, id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       message: 'Application status updated successfully', | ||||||
|  |       application: result.rows[0] | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Update application status error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to update application status' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Update application notes | ||||||
|  | router.put('/:id/notes', authenticateToken, [ | ||||||
|  |   body('notes').notEmpty().trim() | ||||||
|  | ], async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const errors = validationResult(req); | ||||||
|  |     if (!errors.isEmpty()) { | ||||||
|  |       return res.status(400).json({ errors: errors.array() }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const { id } = req.params; | ||||||
|  |     const { notes } = req.body; | ||||||
|  |  | ||||||
|  |     // Check if application exists and user has permission | ||||||
|  |     const applicationResult = await pool.query(` | ||||||
|  |       SELECT a.*, j.employer_id, e.user_id as employer_user_id | ||||||
|  |       FROM applications a | ||||||
|  |       JOIN jobs j ON a.job_id = j.id | ||||||
|  |       JOIN employers e ON j.employer_id = e.id | ||||||
|  |       WHERE a.id = $1 | ||||||
|  |     `, [id]); | ||||||
|  |  | ||||||
|  |     if (applicationResult.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'Application not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const application = applicationResult.rows[0]; | ||||||
|  |  | ||||||
|  |     // Check permissions (employers, recruiters, and admins can add notes) | ||||||
|  |     if (req.user.role === 'candidate') { | ||||||
|  |       return res.status(403).json({ error: 'Candidates cannot add notes to applications' }); | ||||||
|  |     } else if (req.user.role === 'employer') { | ||||||
|  |       if (application.employer_user_id !== req.user.id) { | ||||||
|  |         return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |       } | ||||||
|  |     } else if (req.user.role !== 'admin' && req.user.role !== 'recruiter') { | ||||||
|  |       return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const result = await pool.query( | ||||||
|  |       'UPDATE applications SET notes = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *', | ||||||
|  |       [notes, id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       message: 'Application notes updated successfully', | ||||||
|  |       application: result.rows[0] | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Update application notes error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to update application notes' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | module.exports = router; | ||||||
							
								
								
									
										153
									
								
								backend/src/routes/auth.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								backend/src/routes/auth.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,153 @@ | |||||||
|  | const express = require('express'); | ||||||
|  | const bcrypt = require('bcryptjs'); | ||||||
|  | const jwt = require('jsonwebtoken'); | ||||||
|  | const { body, validationResult } = require('express-validator'); | ||||||
|  | const pool = require('../database/connection'); | ||||||
|  | const { authenticateToken } = require('../middleware/auth'); | ||||||
|  |  | ||||||
|  | const router = express.Router(); | ||||||
|  |  | ||||||
|  | // Register | ||||||
|  | router.post('/register', [ | ||||||
|  |   body('email').isEmail().normalizeEmail(), | ||||||
|  |   body('password').isLength({ min: 6 }), | ||||||
|  |   body('firstName').notEmpty().trim(), | ||||||
|  |   body('lastName').notEmpty().trim(), | ||||||
|  |   body('role').isIn(['recruiter', 'employer', 'candidate']) | ||||||
|  | ], async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const errors = validationResult(req); | ||||||
|  |     if (!errors.isEmpty()) { | ||||||
|  |       return res.status(400).json({ errors: errors.array() }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const { email, password, firstName, lastName, role } = req.body; | ||||||
|  |  | ||||||
|  |     // Check if user already exists | ||||||
|  |     const existingUser = await pool.query( | ||||||
|  |       'SELECT id FROM users WHERE email = $1', | ||||||
|  |       [email] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (existingUser.rows.length > 0) { | ||||||
|  |       return res.status(400).json({ error: 'User already exists' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Hash password | ||||||
|  |     const passwordHash = await bcrypt.hash(password, 10); | ||||||
|  |  | ||||||
|  |     // Create user | ||||||
|  |     const userResult = await pool.query( | ||||||
|  |       'INSERT INTO users (email, password_hash, first_name, last_name, role) VALUES ($1, $2, $3, $4, $5) RETURNING id, email, first_name, last_name, role', | ||||||
|  |       [email, passwordHash, firstName, lastName, role] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     const user = userResult.rows[0]; | ||||||
|  |  | ||||||
|  |     // Generate JWT token | ||||||
|  |     const token = jwt.sign( | ||||||
|  |       { userId: user.id, email: user.email, role: user.role }, | ||||||
|  |       process.env.JWT_SECRET, | ||||||
|  |       { expiresIn: '24h' } | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     res.status(201).json({ | ||||||
|  |       message: 'User created successfully', | ||||||
|  |       token, | ||||||
|  |       user: { | ||||||
|  |         id: user.id, | ||||||
|  |         email: user.email, | ||||||
|  |         firstName: user.first_name, | ||||||
|  |         lastName: user.last_name, | ||||||
|  |         role: user.role | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Registration error:', error); | ||||||
|  |     res.status(500).json({ error: 'Registration failed' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Login | ||||||
|  | router.post('/login', [ | ||||||
|  |   body('email').isEmail().normalizeEmail(), | ||||||
|  |   body('password').notEmpty() | ||||||
|  | ], async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const errors = validationResult(req); | ||||||
|  |     if (!errors.isEmpty()) { | ||||||
|  |       return res.status(400).json({ errors: errors.array() }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const { email, password } = req.body; | ||||||
|  |  | ||||||
|  |     // Get user | ||||||
|  |     const userResult = await pool.query( | ||||||
|  |       'SELECT id, email, password_hash, first_name, last_name, role, is_active FROM users WHERE email = $1', | ||||||
|  |       [email] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (userResult.rows.length === 0) { | ||||||
|  |       return res.status(401).json({ error: 'Invalid credentials' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const user = userResult.rows[0]; | ||||||
|  |  | ||||||
|  |     if (!user.is_active) { | ||||||
|  |       return res.status(401).json({ error: 'Account deactivated' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Verify password | ||||||
|  |     const isValidPassword = await bcrypt.compare(password, user.password_hash); | ||||||
|  |     if (!isValidPassword) { | ||||||
|  |       return res.status(401).json({ error: 'Invalid credentials' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Generate JWT token | ||||||
|  |     const token = jwt.sign( | ||||||
|  |       { userId: user.id, email: user.email, role: user.role }, | ||||||
|  |       process.env.JWT_SECRET, | ||||||
|  |       { expiresIn: '24h' } | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       message: 'Login successful', | ||||||
|  |       token, | ||||||
|  |       user: { | ||||||
|  |         id: user.id, | ||||||
|  |         email: user.email, | ||||||
|  |         firstName: user.first_name, | ||||||
|  |         lastName: user.last_name, | ||||||
|  |         role: user.role | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Login error:', error); | ||||||
|  |     res.status(500).json({ error: 'Login failed' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Get current user | ||||||
|  | router.get('/me', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     res.json({ | ||||||
|  |       user: { | ||||||
|  |         id: req.user.id, | ||||||
|  |         email: req.user.email, | ||||||
|  |         firstName: req.user.first_name, | ||||||
|  |         lastName: req.user.last_name, | ||||||
|  |         role: req.user.role | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Get user error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to get user information' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Logout (client-side token removal) | ||||||
|  | router.post('/logout', authenticateToken, (req, res) => { | ||||||
|  |   res.json({ message: 'Logout successful' }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | module.exports = router; | ||||||
							
								
								
									
										374
									
								
								backend/src/routes/candidates.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										374
									
								
								backend/src/routes/candidates.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,374 @@ | |||||||
|  | const express = require('express'); | ||||||
|  | const { body, validationResult } = require('express-validator'); | ||||||
|  | const pool = require('../database/connection'); | ||||||
|  | const { authenticateToken, requireRole } = require('../middleware/auth'); | ||||||
|  |  | ||||||
|  | const router = express.Router(); | ||||||
|  |  | ||||||
|  | // Get all candidates | ||||||
|  | router.get('/', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { skills, experienceLevel, location, page = 1, limit = 10 } = req.query; | ||||||
|  |  | ||||||
|  |     let query = ` | ||||||
|  |       SELECT c.*, u.email, u.first_name, u.last_name  | ||||||
|  |       FROM candidates c  | ||||||
|  |       JOIN users u ON c.user_id = u.id  | ||||||
|  |     `; | ||||||
|  |     const queryParams = []; | ||||||
|  |     let paramCount = 0; | ||||||
|  |     const conditions = []; | ||||||
|  |  | ||||||
|  |     if (skills) { | ||||||
|  |       const skillArray = skills.split(',').map(s => s.trim()); | ||||||
|  |       paramCount++; | ||||||
|  |       conditions.push(`c.skills && $${paramCount}`); | ||||||
|  |       queryParams.push(skillArray); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (experienceLevel) { | ||||||
|  |       paramCount++; | ||||||
|  |       conditions.push(`c.experience_level = $${paramCount}`); | ||||||
|  |       queryParams.push(experienceLevel); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (location) { | ||||||
|  |       paramCount++; | ||||||
|  |       conditions.push(`c.location ILIKE $${paramCount}`); | ||||||
|  |       queryParams.push(`%${location}%`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (conditions.length > 0) { | ||||||
|  |       query += ` WHERE ${conditions.join(' AND ')}`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     query += ` ORDER BY c.created_at DESC`; | ||||||
|  |  | ||||||
|  |     // Add pagination | ||||||
|  |     const offset = (page - 1) * limit; | ||||||
|  |     paramCount++; | ||||||
|  |     query += ` LIMIT $${paramCount}`; | ||||||
|  |     queryParams.push(limit); | ||||||
|  |     paramCount++; | ||||||
|  |     query += ` OFFSET $${paramCount}`; | ||||||
|  |     queryParams.push(offset); | ||||||
|  |  | ||||||
|  |     const result = await pool.query(query, queryParams); | ||||||
|  |  | ||||||
|  |     // Get total count for pagination | ||||||
|  |     let countQuery = ` | ||||||
|  |       SELECT COUNT(*)  | ||||||
|  |       FROM candidates c  | ||||||
|  |       JOIN users u ON c.user_id = u.id | ||||||
|  |     `; | ||||||
|  |     const countParams = []; | ||||||
|  |     let countParamCount = 0; | ||||||
|  |     const countConditions = []; | ||||||
|  |  | ||||||
|  |     if (skills) { | ||||||
|  |       const skillArray = skills.split(',').map(s => s.trim()); | ||||||
|  |       countParamCount++; | ||||||
|  |       countConditions.push(`c.skills && $${countParamCount}`); | ||||||
|  |       countParams.push(skillArray); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (experienceLevel) { | ||||||
|  |       countParamCount++; | ||||||
|  |       countConditions.push(`c.experience_level = $${countParamCount}`); | ||||||
|  |       countParams.push(experienceLevel); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (location) { | ||||||
|  |       countParamCount++; | ||||||
|  |       countConditions.push(`c.location ILIKE $${countParamCount}`); | ||||||
|  |       countParams.push(`%${location}%`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (countConditions.length > 0) { | ||||||
|  |       countQuery += ` WHERE ${countConditions.join(' AND ')}`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const countResult = await pool.query(countQuery, countParams); | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       candidates: result.rows, | ||||||
|  |       pagination: { | ||||||
|  |         page: parseInt(page), | ||||||
|  |         limit: parseInt(limit), | ||||||
|  |         total: parseInt(countResult.rows[0].count), | ||||||
|  |         pages: Math.ceil(countResult.rows[0].count / limit) | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Get candidates error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to fetch candidates' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Get candidate by ID | ||||||
|  | router.get('/:id', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { id } = req.params; | ||||||
|  |      | ||||||
|  |     const result = await pool.query(` | ||||||
|  |       SELECT c.*, u.email, u.first_name, u.last_name  | ||||||
|  |       FROM candidates c  | ||||||
|  |       JOIN users u ON c.user_id = u.id  | ||||||
|  |       WHERE c.id = $1 | ||||||
|  |     `, [id]); | ||||||
|  |  | ||||||
|  |     if (result.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'Candidate not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     res.json(result.rows[0]); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Get candidate error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to fetch candidate' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Create candidate profile | ||||||
|  | router.post('/', authenticateToken, requireRole(['candidate']), [ | ||||||
|  |   body('phone').optional().trim(), | ||||||
|  |   body('location').optional().trim(), | ||||||
|  |   body('linkedinUrl').optional().isURL(), | ||||||
|  |   body('githubUrl').optional().isURL(), | ||||||
|  |   body('portfolioUrl').optional().isURL(), | ||||||
|  |   body('bio').optional().trim(), | ||||||
|  |   body('skills').optional().isArray(), | ||||||
|  |   body('experienceLevel').optional().isIn(['entry', 'mid', 'senior', 'lead', 'executive']), | ||||||
|  |   body('availability').optional().trim(), | ||||||
|  |   body('salaryExpectation').optional().isInt({ min: 0 }) | ||||||
|  | ], async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const errors = validationResult(req); | ||||||
|  |     if (!errors.isEmpty()) { | ||||||
|  |       return res.status(400).json({ errors: errors.array() }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const { | ||||||
|  |       phone, | ||||||
|  |       location, | ||||||
|  |       linkedinUrl, | ||||||
|  |       githubUrl, | ||||||
|  |       portfolioUrl, | ||||||
|  |       bio, | ||||||
|  |       skills, | ||||||
|  |       experienceLevel, | ||||||
|  |       availability, | ||||||
|  |       salaryExpectation | ||||||
|  |     } = req.body; | ||||||
|  |  | ||||||
|  |     // Check if candidate profile already exists for this user | ||||||
|  |     const existingCandidate = await pool.query( | ||||||
|  |       'SELECT id FROM candidates WHERE user_id = $1', | ||||||
|  |       [req.user.id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (existingCandidate.rows.length > 0) { | ||||||
|  |       return res.status(400).json({ error: 'Candidate profile already exists' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const result = await pool.query(` | ||||||
|  |       INSERT INTO candidates (user_id, phone, location, linkedin_url, github_url, portfolio_url, bio, skills, experience_level, availability, salary_expectation) | ||||||
|  |       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) | ||||||
|  |       RETURNING * | ||||||
|  |     `, [req.user.id, phone, location, linkedinUrl, githubUrl, portfolioUrl, bio, skills, experienceLevel, availability, salaryExpectation]); | ||||||
|  |  | ||||||
|  |     res.status(201).json({ | ||||||
|  |       message: 'Candidate profile created successfully', | ||||||
|  |       candidate: result.rows[0] | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Create candidate error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to create candidate profile' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Update candidate profile | ||||||
|  | router.put('/:id', authenticateToken, [ | ||||||
|  |   body('phone').optional().trim(), | ||||||
|  |   body('location').optional().trim(), | ||||||
|  |   body('linkedinUrl').optional().isURL(), | ||||||
|  |   body('githubUrl').optional().isURL(), | ||||||
|  |   body('portfolioUrl').optional().isURL(), | ||||||
|  |   body('bio').optional().trim(), | ||||||
|  |   body('skills').optional().isArray(), | ||||||
|  |   body('experienceLevel').optional().isIn(['entry', 'mid', 'senior', 'lead', 'executive']), | ||||||
|  |   body('availability').optional().trim(), | ||||||
|  |   body('salaryExpectation').optional().isInt({ min: 0 }) | ||||||
|  | ], async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const errors = validationResult(req); | ||||||
|  |     if (!errors.isEmpty()) { | ||||||
|  |       return res.status(400).json({ errors: errors.array() }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const { id } = req.params; | ||||||
|  |     const { | ||||||
|  |       phone, | ||||||
|  |       location, | ||||||
|  |       linkedinUrl, | ||||||
|  |       githubUrl, | ||||||
|  |       portfolioUrl, | ||||||
|  |       bio, | ||||||
|  |       skills, | ||||||
|  |       experienceLevel, | ||||||
|  |       availability, | ||||||
|  |       salaryExpectation | ||||||
|  |     } = req.body; | ||||||
|  |  | ||||||
|  |     // Check if candidate exists and user has permission | ||||||
|  |     const candidateResult = await pool.query( | ||||||
|  |       'SELECT user_id FROM candidates WHERE id = $1', | ||||||
|  |       [id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (candidateResult.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'Candidate not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Users can only update their own candidate profile unless they're admin | ||||||
|  |     if (candidateResult.rows[0].user_id !== req.user.id && req.user.role !== 'admin') { | ||||||
|  |       return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const updateFields = []; | ||||||
|  |     const updateValues = []; | ||||||
|  |     let paramCount = 1; | ||||||
|  |  | ||||||
|  |     if (phone !== undefined) { | ||||||
|  |       updateFields.push(`phone = $${paramCount}`); | ||||||
|  |       updateValues.push(phone); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (location !== undefined) { | ||||||
|  |       updateFields.push(`location = $${paramCount}`); | ||||||
|  |       updateValues.push(location); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (linkedinUrl !== undefined) { | ||||||
|  |       updateFields.push(`linkedin_url = $${paramCount}`); | ||||||
|  |       updateValues.push(linkedinUrl); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (githubUrl !== undefined) { | ||||||
|  |       updateFields.push(`github_url = $${paramCount}`); | ||||||
|  |       updateValues.push(githubUrl); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (portfolioUrl !== undefined) { | ||||||
|  |       updateFields.push(`portfolio_url = $${paramCount}`); | ||||||
|  |       updateValues.push(portfolioUrl); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (bio !== undefined) { | ||||||
|  |       updateFields.push(`bio = $${paramCount}`); | ||||||
|  |       updateValues.push(bio); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (skills !== undefined) { | ||||||
|  |       updateFields.push(`skills = $${paramCount}`); | ||||||
|  |       updateValues.push(skills); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (experienceLevel !== undefined) { | ||||||
|  |       updateFields.push(`experience_level = $${paramCount}`); | ||||||
|  |       updateValues.push(experienceLevel); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (availability !== undefined) { | ||||||
|  |       updateFields.push(`availability = $${paramCount}`); | ||||||
|  |       updateValues.push(availability); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (salaryExpectation !== undefined) { | ||||||
|  |       updateFields.push(`salary_expectation = $${paramCount}`); | ||||||
|  |       updateValues.push(salaryExpectation); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (updateFields.length === 0) { | ||||||
|  |       return res.status(400).json({ error: 'No fields to update' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     updateValues.push(id); | ||||||
|  |     const query = `UPDATE candidates SET ${updateFields.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = $${paramCount} RETURNING *`; | ||||||
|  |  | ||||||
|  |     const result = await pool.query(query, updateValues); | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       message: 'Candidate profile updated successfully', | ||||||
|  |       candidate: result.rows[0] | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Update candidate error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to update candidate profile' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Get candidate's applications | ||||||
|  | router.get('/:id/applications', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { id } = req.params; | ||||||
|  |     const { status, page = 1, limit = 10 } = req.query; | ||||||
|  |  | ||||||
|  |     let query = ` | ||||||
|  |       SELECT a.*, j.title as job_title, j.employer_id, e.company_name | ||||||
|  |       FROM applications a | ||||||
|  |       JOIN jobs j ON a.job_id = j.id | ||||||
|  |       JOIN employers e ON j.employer_id = e.id | ||||||
|  |       WHERE a.candidate_id = $1 | ||||||
|  |     `; | ||||||
|  |     const queryParams = [id]; | ||||||
|  |     let paramCount = 1; | ||||||
|  |  | ||||||
|  |     if (status) { | ||||||
|  |       paramCount++; | ||||||
|  |       query += ` AND a.status = $${paramCount}`; | ||||||
|  |       queryParams.push(status); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     query += ` ORDER BY a.applied_at DESC`; | ||||||
|  |  | ||||||
|  |     // Add pagination | ||||||
|  |     const offset = (page - 1) * limit; | ||||||
|  |     paramCount++; | ||||||
|  |     query += ` LIMIT $${paramCount}`; | ||||||
|  |     queryParams.push(limit); | ||||||
|  |     paramCount++; | ||||||
|  |     query += ` OFFSET $${paramCount}`; | ||||||
|  |     queryParams.push(offset); | ||||||
|  |  | ||||||
|  |     const result = await pool.query(query, queryParams); | ||||||
|  |  | ||||||
|  |     // Get total count for pagination | ||||||
|  |     let countQuery = ` | ||||||
|  |       SELECT COUNT(*)  | ||||||
|  |       FROM applications a | ||||||
|  |       WHERE a.candidate_id = $1 | ||||||
|  |     `; | ||||||
|  |     const countParams = [id]; | ||||||
|  |     if (status) { | ||||||
|  |       countQuery += ' AND a.status = $2'; | ||||||
|  |       countParams.push(status); | ||||||
|  |     } | ||||||
|  |     const countResult = await pool.query(countQuery, countParams); | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       applications: result.rows, | ||||||
|  |       pagination: { | ||||||
|  |         page: parseInt(page), | ||||||
|  |         limit: parseInt(limit), | ||||||
|  |         total: parseInt(countResult.rows[0].count), | ||||||
|  |         pages: Math.ceil(countResult.rows[0].count / limit) | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Get candidate applications error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to fetch candidate applications' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | module.exports = router; | ||||||
							
								
								
									
										256
									
								
								backend/src/routes/employers.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										256
									
								
								backend/src/routes/employers.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,256 @@ | |||||||
|  | const express = require('express'); | ||||||
|  | const { body, validationResult } = require('express-validator'); | ||||||
|  | const pool = require('../database/connection'); | ||||||
|  | const { authenticateToken, requireRole } = require('../middleware/auth'); | ||||||
|  |  | ||||||
|  | const router = express.Router(); | ||||||
|  |  | ||||||
|  | // Get all employers | ||||||
|  | router.get('/', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const result = await pool.query(` | ||||||
|  |       SELECT e.*, u.email, u.first_name, u.last_name  | ||||||
|  |       FROM employers e  | ||||||
|  |       JOIN users u ON e.user_id = u.id  | ||||||
|  |       ORDER BY e.created_at DESC | ||||||
|  |     `); | ||||||
|  |     res.json(result.rows); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Get employers error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to fetch employers' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Get employer by ID | ||||||
|  | router.get('/:id', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { id } = req.params; | ||||||
|  |      | ||||||
|  |     const result = await pool.query(` | ||||||
|  |       SELECT e.*, u.email, u.first_name, u.last_name  | ||||||
|  |       FROM employers e  | ||||||
|  |       JOIN users u ON e.user_id = u.id  | ||||||
|  |       WHERE e.id = $1 | ||||||
|  |     `, [id]); | ||||||
|  |  | ||||||
|  |     if (result.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'Employer not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     res.json(result.rows[0]); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Get employer error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to fetch employer' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Create employer profile | ||||||
|  | router.post('/', authenticateToken, requireRole(['employer']), [ | ||||||
|  |   body('companyName').notEmpty().trim(), | ||||||
|  |   body('industry').optional().trim(), | ||||||
|  |   body('companySize').optional().trim(), | ||||||
|  |   body('website').optional().isURL(), | ||||||
|  |   body('description').optional().trim(), | ||||||
|  |   body('address').optional().trim(), | ||||||
|  |   body('phone').optional().trim() | ||||||
|  | ], async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const errors = validationResult(req); | ||||||
|  |     if (!errors.isEmpty()) { | ||||||
|  |       return res.status(400).json({ errors: errors.array() }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const { | ||||||
|  |       companyName, | ||||||
|  |       industry, | ||||||
|  |       companySize, | ||||||
|  |       website, | ||||||
|  |       description, | ||||||
|  |       address, | ||||||
|  |       phone | ||||||
|  |     } = req.body; | ||||||
|  |  | ||||||
|  |     // Check if employer profile already exists for this user | ||||||
|  |     const existingEmployer = await pool.query( | ||||||
|  |       'SELECT id FROM employers WHERE user_id = $1', | ||||||
|  |       [req.user.id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (existingEmployer.rows.length > 0) { | ||||||
|  |       return res.status(400).json({ error: 'Employer profile already exists' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const result = await pool.query(` | ||||||
|  |       INSERT INTO employers (user_id, company_name, industry, company_size, website, description, address, phone) | ||||||
|  |       VALUES ($1, $2, $3, $4, $5, $6, $7, $8) | ||||||
|  |       RETURNING * | ||||||
|  |     `, [req.user.id, companyName, industry, companySize, website, description, address, phone]); | ||||||
|  |  | ||||||
|  |     res.status(201).json({ | ||||||
|  |       message: 'Employer profile created successfully', | ||||||
|  |       employer: result.rows[0] | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Create employer error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to create employer profile' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Update employer profile | ||||||
|  | router.put('/:id', authenticateToken, [ | ||||||
|  |   body('companyName').optional().notEmpty().trim(), | ||||||
|  |   body('industry').optional().trim(), | ||||||
|  |   body('companySize').optional().trim(), | ||||||
|  |   body('website').optional().isURL(), | ||||||
|  |   body('description').optional().trim(), | ||||||
|  |   body('address').optional().trim(), | ||||||
|  |   body('phone').optional().trim() | ||||||
|  | ], async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const errors = validationResult(req); | ||||||
|  |     if (!errors.isEmpty()) { | ||||||
|  |       return res.status(400).json({ errors: errors.array() }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const { id } = req.params; | ||||||
|  |     const { | ||||||
|  |       companyName, | ||||||
|  |       industry, | ||||||
|  |       companySize, | ||||||
|  |       website, | ||||||
|  |       description, | ||||||
|  |       address, | ||||||
|  |       phone | ||||||
|  |     } = req.body; | ||||||
|  |  | ||||||
|  |     // Check if employer exists and user has permission | ||||||
|  |     const employerResult = await pool.query( | ||||||
|  |       'SELECT user_id FROM employers WHERE id = $1', | ||||||
|  |       [id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (employerResult.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'Employer not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Users can only update their own employer profile unless they're admin | ||||||
|  |     if (employerResult.rows[0].user_id !== req.user.id && req.user.role !== 'admin') { | ||||||
|  |       return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const updateFields = []; | ||||||
|  |     const updateValues = []; | ||||||
|  |     let paramCount = 1; | ||||||
|  |  | ||||||
|  |     if (companyName) { | ||||||
|  |       updateFields.push(`company_name = $${paramCount}`); | ||||||
|  |       updateValues.push(companyName); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (industry !== undefined) { | ||||||
|  |       updateFields.push(`industry = $${paramCount}`); | ||||||
|  |       updateValues.push(industry); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (companySize !== undefined) { | ||||||
|  |       updateFields.push(`company_size = $${paramCount}`); | ||||||
|  |       updateValues.push(companySize); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (website !== undefined) { | ||||||
|  |       updateFields.push(`website = $${paramCount}`); | ||||||
|  |       updateValues.push(website); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (description !== undefined) { | ||||||
|  |       updateFields.push(`description = $${paramCount}`); | ||||||
|  |       updateValues.push(description); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (address !== undefined) { | ||||||
|  |       updateFields.push(`address = $${paramCount}`); | ||||||
|  |       updateValues.push(address); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (phone !== undefined) { | ||||||
|  |       updateFields.push(`phone = $${paramCount}`); | ||||||
|  |       updateValues.push(phone); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (updateFields.length === 0) { | ||||||
|  |       return res.status(400).json({ error: 'No fields to update' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     updateValues.push(id); | ||||||
|  |     const query = `UPDATE employers SET ${updateFields.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = $${paramCount} RETURNING *`; | ||||||
|  |  | ||||||
|  |     const result = await pool.query(query, updateValues); | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       message: 'Employer profile updated successfully', | ||||||
|  |       employer: result.rows[0] | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Update employer error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to update employer profile' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Get employer's jobs | ||||||
|  | router.get('/:id/jobs', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { id } = req.params; | ||||||
|  |     const { status, page = 1, limit = 10 } = req.query; | ||||||
|  |  | ||||||
|  |     let query = ` | ||||||
|  |       SELECT * FROM jobs  | ||||||
|  |       WHERE employer_id = $1 | ||||||
|  |     `; | ||||||
|  |     const queryParams = [id]; | ||||||
|  |     let paramCount = 1; | ||||||
|  |  | ||||||
|  |     if (status) { | ||||||
|  |       paramCount++; | ||||||
|  |       query += ` AND status = $${paramCount}`; | ||||||
|  |       queryParams.push(status); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     query += ` ORDER BY created_at DESC`; | ||||||
|  |  | ||||||
|  |     // Add pagination | ||||||
|  |     const offset = (page - 1) * limit; | ||||||
|  |     paramCount++; | ||||||
|  |     query += ` LIMIT $${paramCount}`; | ||||||
|  |     queryParams.push(limit); | ||||||
|  |     paramCount++; | ||||||
|  |     query += ` OFFSET $${paramCount}`; | ||||||
|  |     queryParams.push(offset); | ||||||
|  |  | ||||||
|  |     const result = await pool.query(query, queryParams); | ||||||
|  |  | ||||||
|  |     // Get total count for pagination | ||||||
|  |     let countQuery = 'SELECT COUNT(*) FROM jobs WHERE employer_id = $1'; | ||||||
|  |     const countParams = [id]; | ||||||
|  |     if (status) { | ||||||
|  |       countQuery += ' AND status = $2'; | ||||||
|  |       countParams.push(status); | ||||||
|  |     } | ||||||
|  |     const countResult = await pool.query(countQuery, countParams); | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       jobs: result.rows, | ||||||
|  |       pagination: { | ||||||
|  |         page: parseInt(page), | ||||||
|  |         limit: parseInt(limit), | ||||||
|  |         total: parseInt(countResult.rows[0].count), | ||||||
|  |         pages: Math.ceil(countResult.rows[0].count / limit) | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Get employer jobs error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to fetch employer jobs' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | module.exports = router; | ||||||
							
								
								
									
										478
									
								
								backend/src/routes/jobs.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										478
									
								
								backend/src/routes/jobs.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,478 @@ | |||||||
|  | const express = require('express'); | ||||||
|  | const { body, validationResult } = require('express-validator'); | ||||||
|  | const pool = require('../database/connection'); | ||||||
|  | const { authenticateToken, requireRole } = require('../middleware/auth'); | ||||||
|  |  | ||||||
|  | const router = express.Router(); | ||||||
|  |  | ||||||
|  | // Get all jobs with filtering and search | ||||||
|  | router.get('/', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const {  | ||||||
|  |       search,  | ||||||
|  |       location,  | ||||||
|  |       employmentType,  | ||||||
|  |       experienceLevel,  | ||||||
|  |       skills,  | ||||||
|  |       salaryMin,  | ||||||
|  |       salaryMax,  | ||||||
|  |       remoteAllowed, | ||||||
|  |       status = 'active', | ||||||
|  |       page = 1,  | ||||||
|  |       limit = 10  | ||||||
|  |     } = req.query; | ||||||
|  |  | ||||||
|  |     let query = ` | ||||||
|  |       SELECT j.*, e.company_name, e.industry, e.company_size | ||||||
|  |       FROM jobs j | ||||||
|  |       JOIN employers e ON j.employer_id = e.id | ||||||
|  |     `; | ||||||
|  |     const queryParams = []; | ||||||
|  |     let paramCount = 0; | ||||||
|  |     const conditions = []; | ||||||
|  |  | ||||||
|  |     // Always filter by status | ||||||
|  |     paramCount++; | ||||||
|  |     conditions.push(`j.status = $${paramCount}`); | ||||||
|  |     queryParams.push(status); | ||||||
|  |  | ||||||
|  |     if (search) { | ||||||
|  |       paramCount++; | ||||||
|  |       conditions.push(`(j.title ILIKE $${paramCount} OR j.description ILIKE $${paramCount})`); | ||||||
|  |       queryParams.push(`%${search}%`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (location) { | ||||||
|  |       paramCount++; | ||||||
|  |       conditions.push(`j.location ILIKE $${paramCount}`); | ||||||
|  |       queryParams.push(`%${location}%`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (employmentType) { | ||||||
|  |       paramCount++; | ||||||
|  |       conditions.push(`j.employment_type = $${paramCount}`); | ||||||
|  |       queryParams.push(employmentType); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (experienceLevel) { | ||||||
|  |       paramCount++; | ||||||
|  |       conditions.push(`j.experience_level = $${paramCount}`); | ||||||
|  |       queryParams.push(experienceLevel); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (skills) { | ||||||
|  |       const skillArray = skills.split(',').map(s => s.trim()); | ||||||
|  |       paramCount++; | ||||||
|  |       conditions.push(`j.skills_required && $${paramCount}`); | ||||||
|  |       queryParams.push(skillArray); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (salaryMin) { | ||||||
|  |       paramCount++; | ||||||
|  |       conditions.push(`j.salary_max >= $${paramCount}`); | ||||||
|  |       queryParams.push(parseInt(salaryMin)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (salaryMax) { | ||||||
|  |       paramCount++; | ||||||
|  |       conditions.push(`j.salary_min <= $${paramCount}`); | ||||||
|  |       queryParams.push(parseInt(salaryMax)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (remoteAllowed === 'true') { | ||||||
|  |       conditions.push(`j.remote_allowed = true`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (conditions.length > 0) { | ||||||
|  |       query += ` WHERE ${conditions.join(' AND ')}`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     query += ` ORDER BY j.created_at DESC`; | ||||||
|  |  | ||||||
|  |     // Add pagination | ||||||
|  |     const offset = (page - 1) * limit; | ||||||
|  |     paramCount++; | ||||||
|  |     query += ` LIMIT $${paramCount}`; | ||||||
|  |     queryParams.push(limit); | ||||||
|  |     paramCount++; | ||||||
|  |     query += ` OFFSET $${paramCount}`; | ||||||
|  |     queryParams.push(offset); | ||||||
|  |  | ||||||
|  |     const result = await pool.query(query, queryParams); | ||||||
|  |  | ||||||
|  |     // Get total count for pagination | ||||||
|  |     let countQuery = ` | ||||||
|  |       SELECT COUNT(*)  | ||||||
|  |       FROM jobs j | ||||||
|  |       JOIN employers e ON j.employer_id = e.id | ||||||
|  |     `; | ||||||
|  |     const countParams = []; | ||||||
|  |     let countParamCount = 0; | ||||||
|  |     const countConditions = []; | ||||||
|  |  | ||||||
|  |     countParamCount++; | ||||||
|  |     countConditions.push(`j.status = $${countParamCount}`); | ||||||
|  |     countParams.push(status); | ||||||
|  |  | ||||||
|  |     if (search) { | ||||||
|  |       countParamCount++; | ||||||
|  |       countConditions.push(`(j.title ILIKE $${countParamCount} OR j.description ILIKE $${countParamCount})`); | ||||||
|  |       countParams.push(`%${search}%`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (location) { | ||||||
|  |       countParamCount++; | ||||||
|  |       countConditions.push(`j.location ILIKE $${countParamCount}`); | ||||||
|  |       countParams.push(`%${location}%`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (employmentType) { | ||||||
|  |       countParamCount++; | ||||||
|  |       countConditions.push(`j.employment_type = $${countParamCount}`); | ||||||
|  |       countParams.push(employmentType); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (experienceLevel) { | ||||||
|  |       countParamCount++; | ||||||
|  |       countConditions.push(`j.experience_level = $${countParamCount}`); | ||||||
|  |       countParams.push(experienceLevel); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (skills) { | ||||||
|  |       const skillArray = skills.split(',').map(s => s.trim()); | ||||||
|  |       countParamCount++; | ||||||
|  |       countConditions.push(`j.skills_required && $${countParamCount}`); | ||||||
|  |       countParams.push(skillArray); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (salaryMin) { | ||||||
|  |       countParamCount++; | ||||||
|  |       countConditions.push(`j.salary_max >= $${countParamCount}`); | ||||||
|  |       countParams.push(parseInt(salaryMin)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (salaryMax) { | ||||||
|  |       countParamCount++; | ||||||
|  |       countConditions.push(`j.salary_min <= $${countParamCount}`); | ||||||
|  |       countParams.push(parseInt(salaryMax)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (remoteAllowed === 'true') { | ||||||
|  |       countConditions.push(`j.remote_allowed = true`); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (countConditions.length > 0) { | ||||||
|  |       countQuery += ` WHERE ${countConditions.join(' AND ')}`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const countResult = await pool.query(countQuery, countParams); | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       jobs: result.rows, | ||||||
|  |       pagination: { | ||||||
|  |         page: parseInt(page), | ||||||
|  |         limit: parseInt(limit), | ||||||
|  |         total: parseInt(countResult.rows[0].count), | ||||||
|  |         pages: Math.ceil(countResult.rows[0].count / limit) | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Get jobs error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to fetch jobs' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Get job by ID | ||||||
|  | router.get('/:id', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { id } = req.params; | ||||||
|  |      | ||||||
|  |     const result = await pool.query(` | ||||||
|  |       SELECT j.*, e.company_name, e.industry, e.company_size, e.website, e.description as company_description | ||||||
|  |       FROM jobs j | ||||||
|  |       JOIN employers e ON j.employer_id = e.id | ||||||
|  |       WHERE j.id = $1 | ||||||
|  |     `, [id]); | ||||||
|  |  | ||||||
|  |     if (result.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'Job not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     res.json(result.rows[0]); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Get job error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to fetch job' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Create job posting | ||||||
|  | router.post('/', authenticateToken, requireRole(['employer', 'recruiter']), [ | ||||||
|  |   body('title').notEmpty().trim(), | ||||||
|  |   body('description').notEmpty().trim(), | ||||||
|  |   body('requirements').isArray(), | ||||||
|  |   body('responsibilities').isArray(), | ||||||
|  |   body('location').notEmpty().trim(), | ||||||
|  |   body('employmentType').isIn(['full-time', 'part-time', 'contract', 'internship']), | ||||||
|  |   body('salaryMin').optional().isInt({ min: 0 }), | ||||||
|  |   body('salaryMax').optional().isInt({ min: 0 }), | ||||||
|  |   body('currency').optional().isLength({ min: 3, max: 3 }), | ||||||
|  |   body('remoteAllowed').optional().isBoolean(), | ||||||
|  |   body('experienceLevel').optional().isIn(['entry', 'mid', 'senior', 'lead', 'executive']), | ||||||
|  |   body('skillsRequired').optional().isArray(), | ||||||
|  |   body('benefits').optional().isArray(), | ||||||
|  |   body('applicationDeadline').optional().isISO8601() | ||||||
|  | ], async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const errors = validationResult(req); | ||||||
|  |     if (!errors.isEmpty()) { | ||||||
|  |       return res.status(400).json({ errors: errors.array() }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const { | ||||||
|  |       title, | ||||||
|  |       description, | ||||||
|  |       requirements, | ||||||
|  |       responsibilities, | ||||||
|  |       location, | ||||||
|  |       employmentType, | ||||||
|  |       salaryMin, | ||||||
|  |       salaryMax, | ||||||
|  |       currency = 'USD', | ||||||
|  |       remoteAllowed = false, | ||||||
|  |       experienceLevel, | ||||||
|  |       skillsRequired, | ||||||
|  |       benefits, | ||||||
|  |       applicationDeadline | ||||||
|  |     } = req.body; | ||||||
|  |  | ||||||
|  |     // Get employer_id for the current user | ||||||
|  |     let employerId; | ||||||
|  |     if (req.user.role === 'employer') { | ||||||
|  |       const employerResult = await pool.query( | ||||||
|  |         'SELECT id FROM employers WHERE user_id = $1', | ||||||
|  |         [req.user.id] | ||||||
|  |       ); | ||||||
|  |       if (employerResult.rows.length === 0) { | ||||||
|  |         return res.status(400).json({ error: 'Employer profile not found' }); | ||||||
|  |       } | ||||||
|  |       employerId = employerResult.rows[0].id; | ||||||
|  |     } else { | ||||||
|  |       // For recruiters, they need to specify which employer | ||||||
|  |       const { employerId: providedEmployerId } = req.body; | ||||||
|  |       if (!providedEmployerId) { | ||||||
|  |         return res.status(400).json({ error: 'Employer ID required for recruiters' }); | ||||||
|  |       } | ||||||
|  |       employerId = providedEmployerId; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const result = await pool.query(` | ||||||
|  |       INSERT INTO jobs (employer_id, title, description, requirements, responsibilities, location, employment_type, salary_min, salary_max, currency, remote_allowed, experience_level, skills_required, benefits, application_deadline) | ||||||
|  |       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) | ||||||
|  |       RETURNING * | ||||||
|  |     `, [employerId, title, description, requirements, responsibilities, location, employmentType, salaryMin, salaryMax, currency, remoteAllowed, experienceLevel, skillsRequired, benefits, applicationDeadline]); | ||||||
|  |  | ||||||
|  |     res.status(201).json({ | ||||||
|  |       message: 'Job posting created successfully', | ||||||
|  |       job: result.rows[0] | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Create job error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to create job posting' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Update job posting | ||||||
|  | router.put('/:id', authenticateToken, [ | ||||||
|  |   body('title').optional().notEmpty().trim(), | ||||||
|  |   body('description').optional().notEmpty().trim(), | ||||||
|  |   body('requirements').optional().isArray(), | ||||||
|  |   body('responsibilities').optional().isArray(), | ||||||
|  |   body('location').optional().notEmpty().trim(), | ||||||
|  |   body('employmentType').optional().isIn(['full-time', 'part-time', 'contract', 'internship']), | ||||||
|  |   body('salaryMin').optional().isInt({ min: 0 }), | ||||||
|  |   body('salaryMax').optional().isInt({ min: 0 }), | ||||||
|  |   body('currency').optional().isLength({ min: 3, max: 3 }), | ||||||
|  |   body('status').optional().isIn(['active', 'paused', 'closed', 'draft']), | ||||||
|  |   body('remoteAllowed').optional().isBoolean(), | ||||||
|  |   body('experienceLevel').optional().isIn(['entry', 'mid', 'senior', 'lead', 'executive']), | ||||||
|  |   body('skillsRequired').optional().isArray(), | ||||||
|  |   body('benefits').optional().isArray(), | ||||||
|  |   body('applicationDeadline').optional().isISO8601() | ||||||
|  | ], async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const errors = validationResult(req); | ||||||
|  |     if (!errors.isEmpty()) { | ||||||
|  |       return res.status(400).json({ errors: errors.array() }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const { id } = req.params; | ||||||
|  |     const { | ||||||
|  |       title, | ||||||
|  |       description, | ||||||
|  |       requirements, | ||||||
|  |       responsibilities, | ||||||
|  |       location, | ||||||
|  |       employmentType, | ||||||
|  |       salaryMin, | ||||||
|  |       salaryMax, | ||||||
|  |       currency, | ||||||
|  |       status, | ||||||
|  |       remoteAllowed, | ||||||
|  |       experienceLevel, | ||||||
|  |       skillsRequired, | ||||||
|  |       benefits, | ||||||
|  |       applicationDeadline | ||||||
|  |     } = req.body; | ||||||
|  |  | ||||||
|  |     // Check if job exists and user has permission | ||||||
|  |     const jobResult = await pool.query(` | ||||||
|  |       SELECT j.*, e.user_id as employer_user_id | ||||||
|  |       FROM jobs j | ||||||
|  |       JOIN employers e ON j.employer_id = e.id | ||||||
|  |       WHERE j.id = $1 | ||||||
|  |     `, [id]); | ||||||
|  |  | ||||||
|  |     if (jobResult.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'Job not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const job = jobResult.rows[0]; | ||||||
|  |  | ||||||
|  |     // Users can only update jobs from their own employer unless they're admin | ||||||
|  |     if (job.employer_user_id !== req.user.id && req.user.role !== 'admin') { | ||||||
|  |       return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const updateFields = []; | ||||||
|  |     const updateValues = []; | ||||||
|  |     let paramCount = 1; | ||||||
|  |  | ||||||
|  |     if (title) { | ||||||
|  |       updateFields.push(`title = $${paramCount}`); | ||||||
|  |       updateValues.push(title); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (description) { | ||||||
|  |       updateFields.push(`description = $${paramCount}`); | ||||||
|  |       updateValues.push(description); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (requirements) { | ||||||
|  |       updateFields.push(`requirements = $${paramCount}`); | ||||||
|  |       updateValues.push(requirements); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (responsibilities) { | ||||||
|  |       updateFields.push(`responsibilities = $${paramCount}`); | ||||||
|  |       updateValues.push(responsibilities); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (location) { | ||||||
|  |       updateFields.push(`location = $${paramCount}`); | ||||||
|  |       updateValues.push(location); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (employmentType) { | ||||||
|  |       updateFields.push(`employment_type = $${paramCount}`); | ||||||
|  |       updateValues.push(employmentType); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (salaryMin !== undefined) { | ||||||
|  |       updateFields.push(`salary_min = $${paramCount}`); | ||||||
|  |       updateValues.push(salaryMin); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (salaryMax !== undefined) { | ||||||
|  |       updateFields.push(`salary_max = $${paramCount}`); | ||||||
|  |       updateValues.push(salaryMax); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (currency) { | ||||||
|  |       updateFields.push(`currency = $${paramCount}`); | ||||||
|  |       updateValues.push(currency); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (status) { | ||||||
|  |       updateFields.push(`status = $${paramCount}`); | ||||||
|  |       updateValues.push(status); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (remoteAllowed !== undefined) { | ||||||
|  |       updateFields.push(`remote_allowed = $${paramCount}`); | ||||||
|  |       updateValues.push(remoteAllowed); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (experienceLevel) { | ||||||
|  |       updateFields.push(`experience_level = $${paramCount}`); | ||||||
|  |       updateValues.push(experienceLevel); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (skillsRequired) { | ||||||
|  |       updateFields.push(`skills_required = $${paramCount}`); | ||||||
|  |       updateValues.push(skillsRequired); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (benefits) { | ||||||
|  |       updateFields.push(`benefits = $${paramCount}`); | ||||||
|  |       updateValues.push(benefits); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (applicationDeadline) { | ||||||
|  |       updateFields.push(`application_deadline = $${paramCount}`); | ||||||
|  |       updateValues.push(applicationDeadline); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (updateFields.length === 0) { | ||||||
|  |       return res.status(400).json({ error: 'No fields to update' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     updateValues.push(id); | ||||||
|  |     const query = `UPDATE jobs SET ${updateFields.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = $${paramCount} RETURNING *`; | ||||||
|  |  | ||||||
|  |     const result = await pool.query(query, updateValues); | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       message: 'Job posting updated successfully', | ||||||
|  |       job: result.rows[0] | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Update job error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to update job posting' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Delete job posting | ||||||
|  | router.delete('/:id', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { id } = req.params; | ||||||
|  |  | ||||||
|  |     // Check if job exists and user has permission | ||||||
|  |     const jobResult = await pool.query(` | ||||||
|  |       SELECT j.*, e.user_id as employer_user_id | ||||||
|  |       FROM jobs j | ||||||
|  |       JOIN employers e ON j.employer_id = e.id | ||||||
|  |       WHERE j.id = $1 | ||||||
|  |     `, [id]); | ||||||
|  |  | ||||||
|  |     if (jobResult.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'Job not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const job = jobResult.rows[0]; | ||||||
|  |  | ||||||
|  |     // Users can only delete jobs from their own employer unless they're admin | ||||||
|  |     if (job.employer_user_id !== req.user.id && req.user.role !== 'admin') { | ||||||
|  |       return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     await pool.query('DELETE FROM jobs WHERE id = $1', [id]); | ||||||
|  |  | ||||||
|  |     res.json({ message: 'Job posting deleted successfully' }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Delete job error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to delete job posting' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | module.exports = router; | ||||||
							
								
								
									
										298
									
								
								backend/src/routes/resumes.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										298
									
								
								backend/src/routes/resumes.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,298 @@ | |||||||
|  | const express = require('express'); | ||||||
|  | const multer = require('multer'); | ||||||
|  | const path = require('path'); | ||||||
|  | const fs = require('fs'); | ||||||
|  | const { v4: uuidv4 } = require('uuid'); | ||||||
|  | const pool = require('../database/connection'); | ||||||
|  | const { authenticateToken, requireRole } = require('../middleware/auth'); | ||||||
|  |  | ||||||
|  | const router = express.Router(); | ||||||
|  |  | ||||||
|  | // Configure multer for file uploads | ||||||
|  | const storage = multer.diskStorage({ | ||||||
|  |   destination: (req, file, cb) => { | ||||||
|  |     const uploadDir = path.join(__dirname, '../../uploads/resumes'); | ||||||
|  |     if (!fs.existsSync(uploadDir)) { | ||||||
|  |       fs.mkdirSync(uploadDir, { recursive: true }); | ||||||
|  |     } | ||||||
|  |     cb(null, uploadDir); | ||||||
|  |   }, | ||||||
|  |   filename: (req, file, cb) => { | ||||||
|  |     const uniqueName = `${uuidv4()}-${file.originalname}`; | ||||||
|  |     cb(null, uniqueName); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const upload = multer({ | ||||||
|  |   storage: storage, | ||||||
|  |   limits: { | ||||||
|  |     fileSize: 10 * 1024 * 1024 // 10MB limit | ||||||
|  |   }, | ||||||
|  |   fileFilter: (req, file, cb) => { | ||||||
|  |     const allowedTypes = [ | ||||||
|  |       'application/pdf', | ||||||
|  |       'application/msword', | ||||||
|  |       'application/vnd.openxmlformats-officedocument.wordprocessingml.document', | ||||||
|  |       'text/plain' | ||||||
|  |     ]; | ||||||
|  |      | ||||||
|  |     if (allowedTypes.includes(file.mimetype)) { | ||||||
|  |       cb(null, true); | ||||||
|  |     } else { | ||||||
|  |       cb(new Error('Invalid file type. Only PDF, DOC, DOCX, and TXT files are allowed.')); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Get all resumes for a candidate | ||||||
|  | router.get('/candidate/:candidateId', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { candidateId } = req.params; | ||||||
|  |  | ||||||
|  |     // Check permissions | ||||||
|  |     if (req.user.role === 'candidate') { | ||||||
|  |       const candidateResult = await pool.query( | ||||||
|  |         'SELECT id FROM candidates WHERE user_id = $1', | ||||||
|  |         [req.user.id] | ||||||
|  |       ); | ||||||
|  |       if (candidateResult.rows.length === 0 || candidateResult.rows[0].id !== candidateId) { | ||||||
|  |         return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |       } | ||||||
|  |     } else if (req.user.role !== 'admin' && req.user.role !== 'recruiter' && req.user.role !== 'employer') { | ||||||
|  |       return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const result = await pool.query( | ||||||
|  |       'SELECT * FROM resumes WHERE candidate_id = $1 ORDER BY uploaded_at DESC', | ||||||
|  |       [candidateId] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     res.json(result.rows); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Get resumes error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to fetch resumes' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Get resume by ID | ||||||
|  | router.get('/:id', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { id } = req.params; | ||||||
|  |      | ||||||
|  |     const result = await pool.query( | ||||||
|  |       'SELECT * FROM resumes WHERE id = $1', | ||||||
|  |       [id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (result.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'Resume not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const resume = result.rows[0]; | ||||||
|  |  | ||||||
|  |     // Check permissions | ||||||
|  |     if (req.user.role === 'candidate') { | ||||||
|  |       const candidateResult = await pool.query( | ||||||
|  |         'SELECT id FROM candidates WHERE user_id = $1', | ||||||
|  |         [req.user.id] | ||||||
|  |       ); | ||||||
|  |       if (candidateResult.rows.length === 0 || candidateResult.rows[0].id !== resume.candidate_id) { | ||||||
|  |         return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |       } | ||||||
|  |     } else if (req.user.role !== 'admin' && req.user.role !== 'recruiter' && req.user.role !== 'employer') { | ||||||
|  |       return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     res.json(resume); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Get resume error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to fetch resume' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Upload resume | ||||||
|  | router.post('/upload', authenticateToken, requireRole(['candidate']), upload.single('resume'), async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     if (!req.file) { | ||||||
|  |       return res.status(400).json({ error: 'No file uploaded' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Get candidate ID for the current user | ||||||
|  |     const candidateResult = await pool.query( | ||||||
|  |       'SELECT id FROM candidates WHERE user_id = $1', | ||||||
|  |       [req.user.id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (candidateResult.rows.length === 0) { | ||||||
|  |       return res.status(400).json({ error: 'Candidate profile not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const candidateId = candidateResult.rows[0].id; | ||||||
|  |  | ||||||
|  |     // If this is set as primary, unset other primary resumes | ||||||
|  |     if (req.body.isPrimary === 'true') { | ||||||
|  |       await pool.query( | ||||||
|  |         'UPDATE resumes SET is_primary = false WHERE candidate_id = $1', | ||||||
|  |         [candidateId] | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const result = await pool.query(` | ||||||
|  |       INSERT INTO resumes (candidate_id, filename, original_name, file_path, file_size, mime_type, is_primary) | ||||||
|  |       VALUES ($1, $2, $3, $4, $5, $6, $7) | ||||||
|  |       RETURNING * | ||||||
|  |     `, [ | ||||||
|  |       candidateId, | ||||||
|  |       req.file.filename, | ||||||
|  |       req.file.originalname, | ||||||
|  |       req.file.path, | ||||||
|  |       req.file.size, | ||||||
|  |       req.file.mimetype, | ||||||
|  |       req.body.isPrimary === 'true' | ||||||
|  |     ]); | ||||||
|  |  | ||||||
|  |     res.status(201).json({ | ||||||
|  |       message: 'Resume uploaded successfully', | ||||||
|  |       resume: result.rows[0] | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Upload resume error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to upload resume' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Download resume | ||||||
|  | router.get('/:id/download', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { id } = req.params; | ||||||
|  |      | ||||||
|  |     const result = await pool.query( | ||||||
|  |       'SELECT * FROM resumes WHERE id = $1', | ||||||
|  |       [id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (result.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'Resume not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const resume = result.rows[0]; | ||||||
|  |  | ||||||
|  |     // Check permissions | ||||||
|  |     if (req.user.role === 'candidate') { | ||||||
|  |       const candidateResult = await pool.query( | ||||||
|  |         'SELECT id FROM candidates WHERE user_id = $1', | ||||||
|  |         [req.user.id] | ||||||
|  |       ); | ||||||
|  |       if (candidateResult.rows.length === 0 || candidateResult.rows[0].id !== resume.candidate_id) { | ||||||
|  |         return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |       } | ||||||
|  |     } else if (req.user.role !== 'admin' && req.user.role !== 'recruiter' && req.user.role !== 'employer') { | ||||||
|  |       return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Check if file exists | ||||||
|  |     if (!fs.existsSync(resume.file_path)) { | ||||||
|  |       return res.status(404).json({ error: 'Resume file not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     res.download(resume.file_path, resume.original_name); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Download resume error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to download resume' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Set primary resume | ||||||
|  | router.put('/:id/primary', authenticateToken, requireRole(['candidate']), async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { id } = req.params; | ||||||
|  |  | ||||||
|  |     // Get candidate ID for the current user | ||||||
|  |     const candidateResult = await pool.query( | ||||||
|  |       'SELECT id FROM candidates WHERE user_id = $1', | ||||||
|  |       [req.user.id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (candidateResult.rows.length === 0) { | ||||||
|  |       return res.status(400).json({ error: 'Candidate profile not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const candidateId = candidateResult.rows[0].id; | ||||||
|  |  | ||||||
|  |     // Check if resume exists and belongs to the candidate | ||||||
|  |     const resumeResult = await pool.query( | ||||||
|  |       'SELECT id FROM resumes WHERE id = $1 AND candidate_id = $2', | ||||||
|  |       [id, candidateId] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (resumeResult.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'Resume not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Unset other primary resumes | ||||||
|  |     await pool.query( | ||||||
|  |       'UPDATE resumes SET is_primary = false WHERE candidate_id = $1', | ||||||
|  |       [candidateId] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     // Set this resume as primary | ||||||
|  |     const result = await pool.query( | ||||||
|  |       'UPDATE resumes SET is_primary = true WHERE id = $1 RETURNING *', | ||||||
|  |       [id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       message: 'Primary resume updated successfully', | ||||||
|  |       resume: result.rows[0] | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Set primary resume error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to set primary resume' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Delete resume | ||||||
|  | router.delete('/:id', authenticateToken, requireRole(['candidate']), async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { id } = req.params; | ||||||
|  |  | ||||||
|  |     // Get candidate ID for the current user | ||||||
|  |     const candidateResult = await pool.query( | ||||||
|  |       'SELECT id FROM candidates WHERE user_id = $1', | ||||||
|  |       [req.user.id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (candidateResult.rows.length === 0) { | ||||||
|  |       return res.status(400).json({ error: 'Candidate profile not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const candidateId = candidateResult.rows[0].id; | ||||||
|  |  | ||||||
|  |     // Check if resume exists and belongs to the candidate | ||||||
|  |     const resumeResult = await pool.query( | ||||||
|  |       'SELECT * FROM resumes WHERE id = $1 AND candidate_id = $2', | ||||||
|  |       [id, candidateId] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (resumeResult.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'Resume not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const resume = resumeResult.rows[0]; | ||||||
|  |  | ||||||
|  |     // Delete file from filesystem | ||||||
|  |     if (fs.existsSync(resume.file_path)) { | ||||||
|  |       fs.unlinkSync(resume.file_path); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Delete from database | ||||||
|  |     await pool.query('DELETE FROM resumes WHERE id = $1', [id]); | ||||||
|  |  | ||||||
|  |     res.json({ message: 'Resume deleted successfully' }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Delete resume error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to delete resume' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | module.exports = router; | ||||||
							
								
								
									
										165
									
								
								backend/src/routes/users.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								backend/src/routes/users.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,165 @@ | |||||||
|  | const express = require('express'); | ||||||
|  | const { body, validationResult } = require('express-validator'); | ||||||
|  | const pool = require('../database/connection'); | ||||||
|  | const { authenticateToken, requireRole } = require('../middleware/auth'); | ||||||
|  |  | ||||||
|  | const router = express.Router(); | ||||||
|  |  | ||||||
|  | // Get all users (admin only) | ||||||
|  | router.get('/', authenticateToken, requireRole(['admin']), async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const result = await pool.query( | ||||||
|  |       'SELECT id, email, first_name, last_name, role, is_active, created_at FROM users ORDER BY created_at DESC' | ||||||
|  |     ); | ||||||
|  |     res.json(result.rows); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Get users error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to fetch users' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Get user by ID | ||||||
|  | router.get('/:id', authenticateToken, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { id } = req.params; | ||||||
|  |      | ||||||
|  |     // Users can only view their own profile unless they're admin | ||||||
|  |     if (req.user.id !== id && req.user.role !== 'admin') { | ||||||
|  |       return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const result = await pool.query( | ||||||
|  |       'SELECT id, email, first_name, last_name, role, is_active, created_at FROM users WHERE id = $1', | ||||||
|  |       [id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (result.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'User not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     res.json(result.rows[0]); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Get user error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to fetch user' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Update user profile | ||||||
|  | router.put('/:id', authenticateToken, [ | ||||||
|  |   body('firstName').optional().notEmpty().trim(), | ||||||
|  |   body('lastName').optional().notEmpty().trim(), | ||||||
|  |   body('email').optional().isEmail().normalizeEmail() | ||||||
|  | ], async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const errors = validationResult(req); | ||||||
|  |     if (!errors.isEmpty()) { | ||||||
|  |       return res.status(400).json({ errors: errors.array() }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const { id } = req.params; | ||||||
|  |     const { firstName, lastName, email } = req.body; | ||||||
|  |  | ||||||
|  |     // Users can only update their own profile unless they're admin | ||||||
|  |     if (req.user.id !== id && req.user.role !== 'admin') { | ||||||
|  |       return res.status(403).json({ error: 'Access denied' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Check if email is already taken by another user | ||||||
|  |     if (email) { | ||||||
|  |       const existingUser = await pool.query( | ||||||
|  |         'SELECT id FROM users WHERE email = $1 AND id != $2', | ||||||
|  |         [email, id] | ||||||
|  |       ); | ||||||
|  |       if (existingUser.rows.length > 0) { | ||||||
|  |         return res.status(400).json({ error: 'Email already in use' }); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const updateFields = []; | ||||||
|  |     const updateValues = []; | ||||||
|  |     let paramCount = 1; | ||||||
|  |  | ||||||
|  |     if (firstName) { | ||||||
|  |       updateFields.push(`first_name = $${paramCount}`); | ||||||
|  |       updateValues.push(firstName); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (lastName) { | ||||||
|  |       updateFields.push(`last_name = $${paramCount}`); | ||||||
|  |       updateValues.push(lastName); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |     if (email) { | ||||||
|  |       updateFields.push(`email = $${paramCount}`); | ||||||
|  |       updateValues.push(email); | ||||||
|  |       paramCount++; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (updateFields.length === 0) { | ||||||
|  |       return res.status(400).json({ error: 'No fields to update' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     updateValues.push(id); | ||||||
|  |     const query = `UPDATE users SET ${updateFields.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = $${paramCount} RETURNING id, email, first_name, last_name, role, is_active, updated_at`; | ||||||
|  |  | ||||||
|  |     const result = await pool.query(query, updateValues); | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       message: 'User updated successfully', | ||||||
|  |       user: result.rows[0] | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Update user error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to update user' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Deactivate user (admin only) | ||||||
|  | router.put('/:id/deactivate', authenticateToken, requireRole(['admin']), async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { id } = req.params; | ||||||
|  |  | ||||||
|  |     const result = await pool.query( | ||||||
|  |       'UPDATE users SET is_active = false, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, email, first_name, last_name, role, is_active', | ||||||
|  |       [id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (result.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'User not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       message: 'User deactivated successfully', | ||||||
|  |       user: result.rows[0] | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Deactivate user error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to deactivate user' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Activate user (admin only) | ||||||
|  | router.put('/:id/activate', authenticateToken, requireRole(['admin']), async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { id } = req.params; | ||||||
|  |  | ||||||
|  |     const result = await pool.query( | ||||||
|  |       'UPDATE users SET is_active = true, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, email, first_name, last_name, role, is_active', | ||||||
|  |       [id] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     if (result.rows.length === 0) { | ||||||
|  |       return res.status(404).json({ error: 'User not found' }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     res.json({ | ||||||
|  |       message: 'User activated successfully', | ||||||
|  |       user: result.rows[0] | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error('Activate user error:', error); | ||||||
|  |     res.status(500).json({ error: 'Failed to activate user' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | module.exports = router; | ||||||
							
								
								
									
										57
									
								
								backend/src/server.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								backend/src/server.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | |||||||
|  | const express = require('express'); | ||||||
|  | const cors = require('cors'); | ||||||
|  | const helmet = require('helmet'); | ||||||
|  | const morgan = require('morgan'); | ||||||
|  | require('dotenv').config(); | ||||||
|  |  | ||||||
|  | const authRoutes = require('./routes/auth'); | ||||||
|  | const userRoutes = require('./routes/users'); | ||||||
|  | const employerRoutes = require('./routes/employers'); | ||||||
|  | const candidateRoutes = require('./routes/candidates'); | ||||||
|  | const jobRoutes = require('./routes/jobs'); | ||||||
|  | const applicationRoutes = require('./routes/applications'); | ||||||
|  | const resumeRoutes = require('./routes/resumes'); | ||||||
|  |  | ||||||
|  | const app = express(); | ||||||
|  | const PORT = process.env.PORT || 3001; | ||||||
|  |  | ||||||
|  | // Middleware | ||||||
|  | app.use(helmet()); | ||||||
|  | app.use(cors()); | ||||||
|  | app.use(morgan('combined')); | ||||||
|  | app.use(express.json({ limit: '10mb' })); | ||||||
|  | app.use(express.urlencoded({ extended: true })); | ||||||
|  |  | ||||||
|  | // Routes | ||||||
|  | app.use('/api/auth', authRoutes); | ||||||
|  | app.use('/api/users', userRoutes); | ||||||
|  | app.use('/api/employers', employerRoutes); | ||||||
|  | app.use('/api/candidates', candidateRoutes); | ||||||
|  | app.use('/api/jobs', jobRoutes); | ||||||
|  | app.use('/api/applications', applicationRoutes); | ||||||
|  | app.use('/api/resumes', resumeRoutes); | ||||||
|  |  | ||||||
|  | // Health check | ||||||
|  | app.get('/api/health', (req, res) => { | ||||||
|  |   res.json({ status: 'OK', timestamp: new Date().toISOString() }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Error handling middleware | ||||||
|  | app.use((err, req, res, next) => { | ||||||
|  |   console.error(err.stack); | ||||||
|  |   res.status(500).json({  | ||||||
|  |     error: 'Something went wrong!', | ||||||
|  |     message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error' | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // 404 handler | ||||||
|  | app.use('*', (req, res) => { | ||||||
|  |   res.status(404).json({ error: 'Route not found' }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | app.listen(PORT, () => { | ||||||
|  |   console.log(`MysteryApp-Cursor backend server running on port ${PORT}`); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | module.exports = app; | ||||||
							
								
								
									
										185
									
								
								backend/src/tests/auth.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								backend/src/tests/auth.test.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,185 @@ | |||||||
|  | const request = require('supertest'); | ||||||
|  | const app = require('../server'); | ||||||
|  | const pool = require('../database/connection'); | ||||||
|  |  | ||||||
|  | describe('Authentication', () => { | ||||||
|  |   beforeEach(async () => { | ||||||
|  |     // Clean up database before each test | ||||||
|  |     await pool.query('DELETE FROM users WHERE email LIKE $1', ['test%']); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   afterAll(async () => { | ||||||
|  |     await pool.end(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('POST /api/auth/register', () => { | ||||||
|  |     it('should register a new user successfully', async () => { | ||||||
|  |       const userData = { | ||||||
|  |         firstName: 'Test', | ||||||
|  |         lastName: 'User', | ||||||
|  |         email: 'test@example.com', | ||||||
|  |         password: 'password123', | ||||||
|  |         role: 'candidate' | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       const response = await request(app) | ||||||
|  |         .post('/api/auth/register') | ||||||
|  |         .send(userData) | ||||||
|  |         .expect(201); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('message', 'User created successfully'); | ||||||
|  |       expect(response.body).toHaveProperty('token'); | ||||||
|  |       expect(response.body).toHaveProperty('user'); | ||||||
|  |       expect(response.body.user.email).toBe(userData.email); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return error for duplicate email', async () => { | ||||||
|  |       const userData = { | ||||||
|  |         firstName: 'Test', | ||||||
|  |         lastName: 'User', | ||||||
|  |         email: 'test@example.com', | ||||||
|  |         password: 'password123', | ||||||
|  |         role: 'candidate' | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       // Register first user | ||||||
|  |       await request(app) | ||||||
|  |         .post('/api/auth/register') | ||||||
|  |         .send(userData); | ||||||
|  |  | ||||||
|  |       // Try to register with same email | ||||||
|  |       const response = await request(app) | ||||||
|  |         .post('/api/auth/register') | ||||||
|  |         .send(userData) | ||||||
|  |         .expect(400); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('error', 'User already exists'); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return error for invalid role', async () => { | ||||||
|  |       const userData = { | ||||||
|  |         firstName: 'Test', | ||||||
|  |         lastName: 'User', | ||||||
|  |         email: 'test@example.com', | ||||||
|  |         password: 'password123', | ||||||
|  |         role: 'invalid_role' | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       const response = await request(app) | ||||||
|  |         .post('/api/auth/register') | ||||||
|  |         .send(userData) | ||||||
|  |         .expect(400); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('errors'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('POST /api/auth/login', () => { | ||||||
|  |     beforeEach(async () => { | ||||||
|  |       // Create a test user | ||||||
|  |       const userData = { | ||||||
|  |         firstName: 'Test', | ||||||
|  |         lastName: 'User', | ||||||
|  |         email: 'test@example.com', | ||||||
|  |         password: 'password123', | ||||||
|  |         role: 'candidate' | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       await request(app) | ||||||
|  |         .post('/api/auth/register') | ||||||
|  |         .send(userData); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should login with valid credentials', async () => { | ||||||
|  |       const loginData = { | ||||||
|  |         email: 'test@example.com', | ||||||
|  |         password: 'password123' | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       const response = await request(app) | ||||||
|  |         .post('/api/auth/login') | ||||||
|  |         .send(loginData) | ||||||
|  |         .expect(200); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('message', 'Login successful'); | ||||||
|  |       expect(response.body).toHaveProperty('token'); | ||||||
|  |       expect(response.body).toHaveProperty('user'); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return error for invalid credentials', async () => { | ||||||
|  |       const loginData = { | ||||||
|  |         email: 'test@example.com', | ||||||
|  |         password: 'wrongpassword' | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       const response = await request(app) | ||||||
|  |         .post('/api/auth/login') | ||||||
|  |         .send(loginData) | ||||||
|  |         .expect(401); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('error', 'Invalid credentials'); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return error for non-existent user', async () => { | ||||||
|  |       const loginData = { | ||||||
|  |         email: 'nonexistent@example.com', | ||||||
|  |         password: 'password123' | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       const response = await request(app) | ||||||
|  |         .post('/api/auth/login') | ||||||
|  |         .send(loginData) | ||||||
|  |         .expect(401); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('error', 'Invalid credentials'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('GET /api/auth/me', () => { | ||||||
|  |     let token; | ||||||
|  |  | ||||||
|  |     beforeEach(async () => { | ||||||
|  |       // Create a test user and get token | ||||||
|  |       const userData = { | ||||||
|  |         firstName: 'Test', | ||||||
|  |         lastName: 'User', | ||||||
|  |         email: 'test@example.com', | ||||||
|  |         password: 'password123', | ||||||
|  |         role: 'candidate' | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       const registerResponse = await request(app) | ||||||
|  |         .post('/api/auth/register') | ||||||
|  |         .send(userData); | ||||||
|  |  | ||||||
|  |       token = registerResponse.body.token; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return user data with valid token', async () => { | ||||||
|  |       const response = await request(app) | ||||||
|  |         .get('/api/auth/me') | ||||||
|  |         .set('Authorization', `Bearer ${token}`) | ||||||
|  |         .expect(200); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('user'); | ||||||
|  |       expect(response.body.user.email).toBe('test@example.com'); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return error without token', async () => { | ||||||
|  |       const response = await request(app) | ||||||
|  |         .get('/api/auth/me') | ||||||
|  |         .expect(401); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('error', 'Access token required'); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return error with invalid token', async () => { | ||||||
|  |       const response = await request(app) | ||||||
|  |         .get('/api/auth/me') | ||||||
|  |         .set('Authorization', 'Bearer invalid_token') | ||||||
|  |         .expect(403); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('error', 'Invalid or expired token'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										252
									
								
								backend/src/tests/jobs.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										252
									
								
								backend/src/tests/jobs.test.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,252 @@ | |||||||
|  | const request = require('supertest'); | ||||||
|  | const app = require('../server'); | ||||||
|  | const pool = require('../database/connection'); | ||||||
|  |  | ||||||
|  | describe('Jobs API', () => { | ||||||
|  |   let authToken; | ||||||
|  |   let employerId; | ||||||
|  |  | ||||||
|  |   beforeAll(async () => { | ||||||
|  |     // Create test user and get auth token | ||||||
|  |     const userData = { | ||||||
|  |       firstName: 'Test', | ||||||
|  |       lastName: 'Employer', | ||||||
|  |       email: 'employer@test.com', | ||||||
|  |       password: 'password123', | ||||||
|  |       role: 'employer' | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     const registerResponse = await request(app) | ||||||
|  |       .post('/api/auth/register') | ||||||
|  |       .send(userData); | ||||||
|  |  | ||||||
|  |     authToken = registerResponse.body.token; | ||||||
|  |  | ||||||
|  |     // Create employer profile | ||||||
|  |     const employerData = { | ||||||
|  |       companyName: 'Test Company', | ||||||
|  |       industry: 'Technology', | ||||||
|  |       companySize: '50-200', | ||||||
|  |       website: 'https://testcompany.com', | ||||||
|  |       description: 'A test company', | ||||||
|  |       address: '123 Test St', | ||||||
|  |       phone: '+1-555-0123' | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     const employerResponse = await request(app) | ||||||
|  |       .post('/api/employers') | ||||||
|  |       .set('Authorization', `Bearer ${authToken}`) | ||||||
|  |       .send(employerData); | ||||||
|  |  | ||||||
|  |     employerId = employerResponse.body.employer.id; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   afterAll(async () => { | ||||||
|  |     await pool.end(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('POST /api/jobs', () => { | ||||||
|  |     it('should create a new job posting', async () => { | ||||||
|  |       const jobData = { | ||||||
|  |         title: 'Senior Developer', | ||||||
|  |         description: 'We are looking for a senior developer', | ||||||
|  |         requirements: ['5+ years experience', 'JavaScript knowledge'], | ||||||
|  |         responsibilities: ['Develop applications', 'Code reviews'], | ||||||
|  |         location: 'San Francisco, CA', | ||||||
|  |         employmentType: 'full-time', | ||||||
|  |         salaryMin: 100000, | ||||||
|  |         salaryMax: 150000, | ||||||
|  |         remoteAllowed: true, | ||||||
|  |         experienceLevel: 'senior', | ||||||
|  |         skillsRequired: ['JavaScript', 'React', 'Node.js'], | ||||||
|  |         benefits: ['Health insurance', '401k'] | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       const response = await request(app) | ||||||
|  |         .post('/api/jobs') | ||||||
|  |         .set('Authorization', `Bearer ${authToken}`) | ||||||
|  |         .send(jobData) | ||||||
|  |         .expect(201); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('message', 'Job posting created successfully'); | ||||||
|  |       expect(response.body).toHaveProperty('job'); | ||||||
|  |       expect(response.body.job.title).toBe(jobData.title); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return error for missing required fields', async () => { | ||||||
|  |       const jobData = { | ||||||
|  |         title: 'Senior Developer' | ||||||
|  |         // Missing required fields | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       const response = await request(app) | ||||||
|  |         .post('/api/jobs') | ||||||
|  |         .set('Authorization', `Bearer ${authToken}`) | ||||||
|  |         .send(jobData) | ||||||
|  |         .expect(400); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('errors'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('GET /api/jobs', () => { | ||||||
|  |     beforeEach(async () => { | ||||||
|  |       // Create a test job | ||||||
|  |       const jobData = { | ||||||
|  |         title: 'Test Job', | ||||||
|  |         description: 'A test job description', | ||||||
|  |         requirements: ['Test requirement'], | ||||||
|  |         responsibilities: ['Test responsibility'], | ||||||
|  |         location: 'Test Location', | ||||||
|  |         employmentType: 'full-time' | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       await request(app) | ||||||
|  |         .post('/api/jobs') | ||||||
|  |         .set('Authorization', `Bearer ${authToken}`) | ||||||
|  |         .send(jobData); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return list of jobs', async () => { | ||||||
|  |       const response = await request(app) | ||||||
|  |         .get('/api/jobs') | ||||||
|  |         .set('Authorization', `Bearer ${authToken}`) | ||||||
|  |         .expect(200); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('jobs'); | ||||||
|  |       expect(response.body).toHaveProperty('pagination'); | ||||||
|  |       expect(Array.isArray(response.body.jobs)).toBe(true); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should filter jobs by search term', async () => { | ||||||
|  |       const response = await request(app) | ||||||
|  |         .get('/api/jobs?search=Test') | ||||||
|  |         .set('Authorization', `Bearer ${authToken}`) | ||||||
|  |         .expect(200); | ||||||
|  |  | ||||||
|  |       expect(response.body.jobs.length).toBeGreaterThan(0); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should filter jobs by location', async () => { | ||||||
|  |       const response = await request(app) | ||||||
|  |         .get('/api/jobs?location=Test') | ||||||
|  |         .set('Authorization', `Bearer ${authToken}`) | ||||||
|  |         .expect(200); | ||||||
|  |  | ||||||
|  |       expect(response.body.jobs.length).toBeGreaterThan(0); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('GET /api/jobs/:id', () => { | ||||||
|  |     let jobId; | ||||||
|  |  | ||||||
|  |     beforeEach(async () => { | ||||||
|  |       // Create a test job | ||||||
|  |       const jobData = { | ||||||
|  |         title: 'Test Job for Details', | ||||||
|  |         description: 'A test job description', | ||||||
|  |         requirements: ['Test requirement'], | ||||||
|  |         responsibilities: ['Test responsibility'], | ||||||
|  |         location: 'Test Location', | ||||||
|  |         employmentType: 'full-time' | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       const response = await request(app) | ||||||
|  |         .post('/api/jobs') | ||||||
|  |         .set('Authorization', `Bearer ${authToken}`) | ||||||
|  |         .send(jobData); | ||||||
|  |  | ||||||
|  |       jobId = response.body.job.id; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return job details', async () => { | ||||||
|  |       const response = await request(app) | ||||||
|  |         .get(`/api/jobs/${jobId}`) | ||||||
|  |         .set('Authorization', `Bearer ${authToken}`) | ||||||
|  |         .expect(200); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('id', jobId); | ||||||
|  |       expect(response.body).toHaveProperty('title', 'Test Job for Details'); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should return 404 for non-existent job', async () => { | ||||||
|  |       const response = await request(app) | ||||||
|  |         .get('/api/jobs/00000000-0000-0000-0000-000000000000') | ||||||
|  |         .set('Authorization', `Bearer ${authToken}`) | ||||||
|  |         .expect(404); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('error', 'Job not found'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('PUT /api/jobs/:id', () => { | ||||||
|  |     let jobId; | ||||||
|  |  | ||||||
|  |     beforeEach(async () => { | ||||||
|  |       // Create a test job | ||||||
|  |       const jobData = { | ||||||
|  |         title: 'Test Job for Update', | ||||||
|  |         description: 'A test job description', | ||||||
|  |         requirements: ['Test requirement'], | ||||||
|  |         responsibilities: ['Test responsibility'], | ||||||
|  |         location: 'Test Location', | ||||||
|  |         employmentType: 'full-time' | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       const response = await request(app) | ||||||
|  |         .post('/api/jobs') | ||||||
|  |         .set('Authorization', `Bearer ${authToken}`) | ||||||
|  |         .send(jobData); | ||||||
|  |  | ||||||
|  |       jobId = response.body.job.id; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should update job successfully', async () => { | ||||||
|  |       const updateData = { | ||||||
|  |         title: 'Updated Test Job', | ||||||
|  |         description: 'Updated description' | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       const response = await request(app) | ||||||
|  |         .put(`/api/jobs/${jobId}`) | ||||||
|  |         .set('Authorization', `Bearer ${authToken}`) | ||||||
|  |         .send(updateData) | ||||||
|  |         .expect(200); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('message', 'Job posting updated successfully'); | ||||||
|  |       expect(response.body.job.title).toBe('Updated Test Job'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('DELETE /api/jobs/:id', () => { | ||||||
|  |     let jobId; | ||||||
|  |  | ||||||
|  |     beforeEach(async () => { | ||||||
|  |       // Create a test job | ||||||
|  |       const jobData = { | ||||||
|  |         title: 'Test Job for Delete', | ||||||
|  |         description: 'A test job description', | ||||||
|  |         requirements: ['Test requirement'], | ||||||
|  |         responsibilities: ['Test responsibility'], | ||||||
|  |         location: 'Test Location', | ||||||
|  |         employmentType: 'full-time' | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       const response = await request(app) | ||||||
|  |         .post('/api/jobs') | ||||||
|  |         .set('Authorization', `Bearer ${authToken}`) | ||||||
|  |         .send(jobData); | ||||||
|  |  | ||||||
|  |       jobId = response.body.job.id; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should delete job successfully', async () => { | ||||||
|  |       const response = await request(app) | ||||||
|  |         .delete(`/api/jobs/${jobId}`) | ||||||
|  |         .set('Authorization', `Bearer ${authToken}`) | ||||||
|  |         .expect(200); | ||||||
|  |  | ||||||
|  |       expect(response.body).toHaveProperty('message', 'Job posting deleted successfully'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										58
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | |||||||
|  | services: | ||||||
|  |   mysteryapp-cursor-database: | ||||||
|  |     image: postgres:15-alpine | ||||||
|  |     container_name: mysteryapp-cursor-database | ||||||
|  |     environment: | ||||||
|  |       POSTGRES_DB: mysteryapp_cursor | ||||||
|  |       POSTGRES_USER: mysteryapp_user | ||||||
|  |       POSTGRES_PASSWORD: mysteryapp_password | ||||||
|  |     ports: | ||||||
|  |       - "0.0.0.0:5432:5432" | ||||||
|  |     volumes: | ||||||
|  |       - mysteryapp-cursor-postgres-data:/var/lib/postgresql/data | ||||||
|  |     networks: | ||||||
|  |       - mysteryapp-cursor-network | ||||||
|  |  | ||||||
|  |   mysteryapp-cursor-backend: | ||||||
|  |     build: | ||||||
|  |       context: ./backend | ||||||
|  |       dockerfile: Dockerfile | ||||||
|  |     container_name: mysteryapp-cursor-backend | ||||||
|  |     environment: | ||||||
|  |       NODE_ENV: development | ||||||
|  |       DATABASE_URL: postgresql://mysteryapp_user:mysteryapp_password@mysteryapp-cursor-database:5432/mysteryapp_cursor | ||||||
|  |       JWT_SECRET: mysteryapp_jwt_secret_key_2024 | ||||||
|  |       PORT: 3001 | ||||||
|  |     ports: | ||||||
|  |       - "0.0.0.0:3001:3001" | ||||||
|  |     depends_on: | ||||||
|  |       - mysteryapp-cursor-database | ||||||
|  |     volumes: | ||||||
|  |       - ./backend:/app | ||||||
|  |       - /app/node_modules | ||||||
|  |     networks: | ||||||
|  |       - mysteryapp-cursor-network | ||||||
|  |  | ||||||
|  |   mysteryapp-cursor-frontend: | ||||||
|  |     build: | ||||||
|  |       context: ./frontend | ||||||
|  |       dockerfile: Dockerfile | ||||||
|  |     container_name: mysteryapp-cursor-frontend | ||||||
|  |     environment: | ||||||
|  |       REACT_APP_API_URL: http://localhost:3001 | ||||||
|  |     ports: | ||||||
|  |       - "0.0.0.0:12000:3000" | ||||||
|  |     depends_on: | ||||||
|  |       - mysteryapp-cursor-backend | ||||||
|  |     volumes: | ||||||
|  |       - ./frontend:/app | ||||||
|  |       - /app/node_modules | ||||||
|  |     networks: | ||||||
|  |       - mysteryapp-cursor-network | ||||||
|  |  | ||||||
|  | volumes: | ||||||
|  |   mysteryapp-cursor-postgres-data: | ||||||
|  |  | ||||||
|  | networks: | ||||||
|  |   mysteryapp-cursor-network: | ||||||
|  |     driver: bridge | ||||||
							
								
								
									
										12
									
								
								frontend/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | FROM node:18-alpine | ||||||
|  |  | ||||||
|  | WORKDIR /app | ||||||
|  |  | ||||||
|  | COPY package*.json ./ | ||||||
|  | RUN npm install | ||||||
|  |  | ||||||
|  | COPY . . | ||||||
|  |  | ||||||
|  | EXPOSE 3000 | ||||||
|  |  | ||||||
|  | CMD ["npm", "start"] | ||||||
							
								
								
									
										53
									
								
								frontend/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								frontend/package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | |||||||
|  | { | ||||||
|  |   "name": "mysteryapp-cursor-frontend", | ||||||
|  |   "version": "1.0.0", | ||||||
|  |   "description": "Frontend for MysteryApp-Cursor recruiter workflow SAAS", | ||||||
|  |   "private": true, | ||||||
|  |   "dependencies": { | ||||||
|  |     "@testing-library/jest-dom": "^5.17.0", | ||||||
|  |     "@testing-library/react": "^13.4.0", | ||||||
|  |     "@testing-library/user-event": "^14.5.2", | ||||||
|  |     "react": "^18.2.0", | ||||||
|  |     "react-dom": "^18.2.0", | ||||||
|  |     "react-router-dom": "^6.8.1", | ||||||
|  |     "react-scripts": "5.0.1", | ||||||
|  |     "axios": "^1.6.2", | ||||||
|  |     "react-hook-form": "^7.48.2", | ||||||
|  |     "react-query": "^3.39.3", | ||||||
|  |     "react-hot-toast": "^2.4.1", | ||||||
|  |     "lucide-react": "^0.294.0", | ||||||
|  |     "clsx": "^2.0.0", | ||||||
|  |     "tailwindcss": "^3.3.6", | ||||||
|  |     "autoprefixer": "^10.4.16", | ||||||
|  |     "postcss": "^8.4.32" | ||||||
|  |   }, | ||||||
|  |   "scripts": { | ||||||
|  |     "start": "react-scripts start", | ||||||
|  |     "build": "react-scripts build", | ||||||
|  |     "test": "react-scripts test", | ||||||
|  |     "eject": "react-scripts eject" | ||||||
|  |   }, | ||||||
|  |   "eslintConfig": { | ||||||
|  |     "extends": [ | ||||||
|  |       "react-app", | ||||||
|  |       "react-app/jest" | ||||||
|  |     ] | ||||||
|  |   }, | ||||||
|  |   "browserslist": { | ||||||
|  |     "production": [ | ||||||
|  |       ">0.2%", | ||||||
|  |       "not dead", | ||||||
|  |       "not op_mini all" | ||||||
|  |     ], | ||||||
|  |     "development": [ | ||||||
|  |       "last 1 chrome version", | ||||||
|  |       "last 1 firefox version", | ||||||
|  |       "last 1 safari version" | ||||||
|  |     ] | ||||||
|  |   }, | ||||||
|  |   "devDependencies": { | ||||||
|  |     "@types/react": "^18.2.42", | ||||||
|  |     "@types/react-dom": "^18.2.17" | ||||||
|  |   }, | ||||||
|  |   "proxy": "http://MysteryApp-Cursor-backend:3001" | ||||||
|  | } | ||||||
							
								
								
									
										20
									
								
								frontend/public/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								frontend/public/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="en"> | ||||||
|  |   <head> | ||||||
|  |     <meta charset="utf-8" /> | ||||||
|  |     <link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> | ||||||
|  |     <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||||
|  |     <meta name="theme-color" content="#000000" /> | ||||||
|  |     <meta | ||||||
|  |       name="description" | ||||||
|  |       content="MysteryApp-Cursor - Professional Recruiter Workflow SAAS" | ||||||
|  |     /> | ||||||
|  |     <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> | ||||||
|  |     <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> | ||||||
|  |     <title>MysteryApp-Cursor</title> | ||||||
|  |   </head> | ||||||
|  |   <body> | ||||||
|  |     <noscript>You need to enable JavaScript to run this app.</noscript> | ||||||
|  |     <div id="root"></div> | ||||||
|  |   </body> | ||||||
|  | </html> | ||||||
							
								
								
									
										136
									
								
								frontend/src/App.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								frontend/src/App.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,136 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; | ||||||
|  | import { QueryClient, QueryClientProvider } from 'react-query'; | ||||||
|  | import { Toaster } from 'react-hot-toast'; | ||||||
|  | import { AuthProvider, useAuth } from './contexts/AuthContext'; | ||||||
|  | import Layout from './components/Layout'; | ||||||
|  | import Login from './pages/Login'; | ||||||
|  | import Register from './pages/Register'; | ||||||
|  | import Dashboard from './pages/Dashboard'; | ||||||
|  | import Jobs from './pages/Jobs'; | ||||||
|  | import JobDetails from './pages/JobDetails'; | ||||||
|  | import CreateJob from './pages/CreateJob'; | ||||||
|  | import Candidates from './pages/Candidates'; | ||||||
|  | import CandidateDetails from './pages/CandidateDetails'; | ||||||
|  | import Applications from './pages/Applications'; | ||||||
|  | import Profile from './pages/Profile'; | ||||||
|  | import Employers from './pages/Employers'; | ||||||
|  | import EmployerDetails from './pages/EmployerDetails'; | ||||||
|  | import Resumes from './pages/Resumes'; | ||||||
|  |  | ||||||
|  | const queryClient = new QueryClient(); | ||||||
|  |  | ||||||
|  | function ProtectedRoute({ children, allowedRoles = [] }) { | ||||||
|  |   const { user, loading } = useAuth(); | ||||||
|  |  | ||||||
|  |   if (loading) { | ||||||
|  |     return ( | ||||||
|  |       <div className="min-h-screen flex items-center justify-center"> | ||||||
|  |         <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (!user) { | ||||||
|  |     return <Navigate to="/login" replace />; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (allowedRoles.length > 0 && !allowedRoles.includes(user.role)) { | ||||||
|  |     return <Navigate to="/dashboard" replace />; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return children; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function AppRoutes() { | ||||||
|  |   const { user } = useAuth(); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Routes> | ||||||
|  |       <Route path="/login" element={!user ? <Login /> : <Navigate to="/dashboard" replace />} /> | ||||||
|  |       <Route path="/register" element={!user ? <Register /> : <Navigate to="/dashboard" replace />} /> | ||||||
|  |        | ||||||
|  |       <Route path="/" element={<Layout />}> | ||||||
|  |         <Route index element={<Navigate to="/dashboard" replace />} /> | ||||||
|  |         <Route path="dashboard" element={ | ||||||
|  |           <ProtectedRoute> | ||||||
|  |             <Dashboard /> | ||||||
|  |           </ProtectedRoute> | ||||||
|  |         } /> | ||||||
|  |          | ||||||
|  |         <Route path="jobs" element={ | ||||||
|  |           <ProtectedRoute> | ||||||
|  |             <Jobs /> | ||||||
|  |           </ProtectedRoute> | ||||||
|  |         } /> | ||||||
|  |         <Route path="jobs/create" element={ | ||||||
|  |           <ProtectedRoute allowedRoles={['employer', 'recruiter']}> | ||||||
|  |             <CreateJob /> | ||||||
|  |           </ProtectedRoute> | ||||||
|  |         } /> | ||||||
|  |         <Route path="jobs/:id" element={ | ||||||
|  |           <ProtectedRoute> | ||||||
|  |             <JobDetails /> | ||||||
|  |           </ProtectedRoute> | ||||||
|  |         } /> | ||||||
|  |          | ||||||
|  |         <Route path="candidates" element={ | ||||||
|  |           <ProtectedRoute allowedRoles={['admin', 'recruiter', 'employer']}> | ||||||
|  |             <Candidates /> | ||||||
|  |           </ProtectedRoute> | ||||||
|  |         } /> | ||||||
|  |         <Route path="candidates/:id" element={ | ||||||
|  |           <ProtectedRoute allowedRoles={['admin', 'recruiter', 'employer']}> | ||||||
|  |             <CandidateDetails /> | ||||||
|  |           </ProtectedRoute> | ||||||
|  |         } /> | ||||||
|  |          | ||||||
|  |         <Route path="applications" element={ | ||||||
|  |           <ProtectedRoute> | ||||||
|  |             <Applications /> | ||||||
|  |           </ProtectedRoute> | ||||||
|  |         } /> | ||||||
|  |          | ||||||
|  |         <Route path="employers" element={ | ||||||
|  |           <ProtectedRoute allowedRoles={['admin', 'recruiter']}> | ||||||
|  |             <Employers /> | ||||||
|  |           </ProtectedRoute> | ||||||
|  |         } /> | ||||||
|  |         <Route path="employers/:id" element={ | ||||||
|  |           <ProtectedRoute allowedRoles={['admin', 'recruiter']}> | ||||||
|  |             <EmployerDetails /> | ||||||
|  |           </ProtectedRoute> | ||||||
|  |         } /> | ||||||
|  |          | ||||||
|  |         <Route path="resumes" element={ | ||||||
|  |           <ProtectedRoute allowedRoles={['candidate']}> | ||||||
|  |             <Resumes /> | ||||||
|  |           </ProtectedRoute> | ||||||
|  |         } /> | ||||||
|  |          | ||||||
|  |         <Route path="profile" element={ | ||||||
|  |           <ProtectedRoute> | ||||||
|  |             <Profile /> | ||||||
|  |           </ProtectedRoute> | ||||||
|  |         } /> | ||||||
|  |       </Route> | ||||||
|  |     </Routes> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function App() { | ||||||
|  |   return ( | ||||||
|  |     <QueryClientProvider client={queryClient}> | ||||||
|  |       <AuthProvider> | ||||||
|  |         <Router> | ||||||
|  |           <div className="App"> | ||||||
|  |             <AppRoutes /> | ||||||
|  |             <Toaster position="top-right" /> | ||||||
|  |           </div> | ||||||
|  |         </Router> | ||||||
|  |       </AuthProvider> | ||||||
|  |     </QueryClientProvider> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default App; | ||||||
							
								
								
									
										43
									
								
								frontend/src/App.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								frontend/src/App.test.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import { render, screen } from '@testing-library/react'; | ||||||
|  | import App from './App'; | ||||||
|  |  | ||||||
|  | // Mock the AuthContext | ||||||
|  | jest.mock('./contexts/AuthContext', () => ({ | ||||||
|  |   useAuth: () => ({ | ||||||
|  |     user: null, | ||||||
|  |     loading: false, | ||||||
|  |     login: jest.fn(), | ||||||
|  |     register: jest.fn(), | ||||||
|  |     logout: jest.fn(), | ||||||
|  |     fetchUser: jest.fn() | ||||||
|  |   }), | ||||||
|  |   AuthProvider: ({ children }) => children | ||||||
|  | })); | ||||||
|  |  | ||||||
|  | // Mock react-router-dom | ||||||
|  | jest.mock('react-router-dom', () => ({ | ||||||
|  |   BrowserRouter: ({ children }) => <div>{children}</div>, | ||||||
|  |   Routes: ({ children }) => <div>{children}</div>, | ||||||
|  |   Route: ({ children }) => <div>{children}</div>, | ||||||
|  |   Navigate: ({ to }) => <div data-testid="navigate">{to}</div>, | ||||||
|  |   Outlet: () => <div data-testid="outlet">Outlet</div> | ||||||
|  | })); | ||||||
|  |  | ||||||
|  | // Mock react-query | ||||||
|  | jest.mock('react-query', () => ({ | ||||||
|  |   QueryClient: jest.fn(() => ({})), | ||||||
|  |   QueryClientProvider: ({ children }) => <div>{children}</div> | ||||||
|  | })); | ||||||
|  |  | ||||||
|  | // Mock react-hot-toast | ||||||
|  | jest.mock('react-hot-toast', () => ({ | ||||||
|  |   Toaster: () => <div data-testid="toaster">Toaster</div> | ||||||
|  | })); | ||||||
|  |  | ||||||
|  | describe('App', () => { | ||||||
|  |   it('renders without crashing', () => { | ||||||
|  |     render(<App />); | ||||||
|  |     expect(screen.getByTestId('toaster')).toBeInTheDocument(); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										186
									
								
								frontend/src/components/Layout.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										186
									
								
								frontend/src/components/Layout.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,186 @@ | |||||||
|  | import React, { useState } from 'react'; | ||||||
|  | import { Link, useLocation, Outlet } from 'react-router-dom'; | ||||||
|  | import { useAuth } from '../contexts/AuthContext'; | ||||||
|  | import { | ||||||
|  |   Home, | ||||||
|  |   Briefcase, | ||||||
|  |   Users, | ||||||
|  |   FileText, | ||||||
|  |   Building, | ||||||
|  |   User, | ||||||
|  |   LogOut, | ||||||
|  |   Menu, | ||||||
|  |   X, | ||||||
|  |   Bell | ||||||
|  | } from 'lucide-react'; | ||||||
|  |  | ||||||
|  | const Layout = () => { | ||||||
|  |   const { user, logout } = useAuth(); | ||||||
|  |   const location = useLocation(); | ||||||
|  |   const [sidebarOpen, setSidebarOpen] = useState(false); | ||||||
|  |  | ||||||
|  |   const navigation = [ | ||||||
|  |     { name: 'Dashboard', href: '/dashboard', icon: Home, roles: ['admin', 'recruiter', 'employer', 'candidate'] }, | ||||||
|  |     { name: 'Jobs', href: '/jobs', icon: Briefcase, roles: ['admin', 'recruiter', 'employer', 'candidate'] }, | ||||||
|  |     { name: 'Candidates', href: '/candidates', icon: Users, roles: ['admin', 'recruiter', 'employer'] }, | ||||||
|  |     { name: 'Applications', href: '/applications', icon: FileText, roles: ['admin', 'recruiter', 'employer', 'candidate'] }, | ||||||
|  |     { name: 'Employers', href: '/employers', icon: Building, roles: ['admin', 'recruiter'] }, | ||||||
|  |     { name: 'Resumes', href: '/resumes', icon: FileText, roles: ['candidate'] }, | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   const filteredNavigation = navigation.filter(item =>  | ||||||
|  |     item.roles.includes(user?.role) | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const handleLogout = () => { | ||||||
|  |     logout(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="min-h-screen bg-gray-50"> | ||||||
|  |       {/* Mobile sidebar */} | ||||||
|  |       <div className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? 'block' : 'hidden'}`}> | ||||||
|  |         <div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setSidebarOpen(false)} /> | ||||||
|  |         <div className="relative flex-1 flex flex-col max-w-xs w-full bg-white"> | ||||||
|  |           <div className="absolute top-0 right-0 -mr-12 pt-2"> | ||||||
|  |             <button | ||||||
|  |               type="button" | ||||||
|  |               className="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white" | ||||||
|  |               onClick={() => setSidebarOpen(false)} | ||||||
|  |             > | ||||||
|  |               <X className="h-6 w-6 text-white" /> | ||||||
|  |             </button> | ||||||
|  |           </div> | ||||||
|  |           <div className="flex-1 h-0 pt-5 pb-4 overflow-y-auto"> | ||||||
|  |             <div className="flex-shrink-0 flex items-center px-4"> | ||||||
|  |               <h1 className="text-xl font-bold text-gray-900">MysteryApp-Cursor</h1> | ||||||
|  |             </div> | ||||||
|  |             <nav className="mt-5 px-2 space-y-1"> | ||||||
|  |               {filteredNavigation.map((item) => { | ||||||
|  |                 const isActive = location.pathname === item.href; | ||||||
|  |                 return ( | ||||||
|  |                   <Link | ||||||
|  |                     key={item.name} | ||||||
|  |                     to={item.href} | ||||||
|  |                     className={`${ | ||||||
|  |                       isActive | ||||||
|  |                         ? 'bg-primary-100 text-primary-900' | ||||||
|  |                         : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' | ||||||
|  |                     } group flex items-center px-2 py-2 text-base font-medium rounded-md`} | ||||||
|  |                   > | ||||||
|  |                     <item.icon className="mr-4 h-6 w-6" /> | ||||||
|  |                     {item.name} | ||||||
|  |                   </Link> | ||||||
|  |                 ); | ||||||
|  |               })} | ||||||
|  |             </nav> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {/* Desktop sidebar */} | ||||||
|  |       <div className="hidden lg:flex lg:w-64 lg:flex-col lg:fixed lg:inset-y-0"> | ||||||
|  |         <div className="flex-1 flex flex-col min-h-0 border-r border-gray-200 bg-white"> | ||||||
|  |           <div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto"> | ||||||
|  |             <div className="flex items-center flex-shrink-0 px-4"> | ||||||
|  |               <h1 className="text-xl font-bold text-gray-900">MysteryApp-Cursor</h1> | ||||||
|  |             </div> | ||||||
|  |             <nav className="mt-5 flex-1 px-2 space-y-1"> | ||||||
|  |               {filteredNavigation.map((item) => { | ||||||
|  |                 const isActive = location.pathname === item.href; | ||||||
|  |                 return ( | ||||||
|  |                   <Link | ||||||
|  |                     key={item.name} | ||||||
|  |                     to={item.href} | ||||||
|  |                     className={`${ | ||||||
|  |                       isActive | ||||||
|  |                         ? 'bg-primary-100 text-primary-900' | ||||||
|  |                         : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' | ||||||
|  |                     } group flex items-center px-2 py-2 text-sm font-medium rounded-md`} | ||||||
|  |                   > | ||||||
|  |                     <item.icon className="mr-3 h-6 w-6" /> | ||||||
|  |                     {item.name} | ||||||
|  |                   </Link> | ||||||
|  |                 ); | ||||||
|  |               })} | ||||||
|  |             </nav> | ||||||
|  |           </div> | ||||||
|  |           <div className="flex-shrink-0 flex border-t border-gray-200 p-4"> | ||||||
|  |             <div className="flex-shrink-0 w-full group block"> | ||||||
|  |               <div className="flex items-center"> | ||||||
|  |                 <div className="ml-3"> | ||||||
|  |                   <p className="text-sm font-medium text-gray-700 group-hover:text-gray-900"> | ||||||
|  |                     {user?.firstName} {user?.lastName} | ||||||
|  |                   </p> | ||||||
|  |                   <p className="text-xs font-medium text-gray-500 group-hover:text-gray-700"> | ||||||
|  |                     {user?.role} | ||||||
|  |                   </p> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {/* Main content */} | ||||||
|  |       <div className="lg:pl-64 flex flex-col flex-1"> | ||||||
|  |         <div className="sticky top-0 z-10 lg:hidden pl-1 pt-1 sm:pl-3 sm:pt-3 bg-gray-100"> | ||||||
|  |           <button | ||||||
|  |             type="button" | ||||||
|  |             className="-ml-0.5 -mt-0.5 h-12 w-12 inline-flex items-center justify-center rounded-md text-gray-500 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500" | ||||||
|  |             onClick={() => setSidebarOpen(true)} | ||||||
|  |           > | ||||||
|  |             <Menu className="h-6 w-6" /> | ||||||
|  |           </button> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <main className="flex-1"> | ||||||
|  |           <div className="py-6"> | ||||||
|  |             <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> | ||||||
|  |               <Outlet /> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </main> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {/* Top bar for desktop */} | ||||||
|  |       <div className="hidden lg:block lg:pl-64"> | ||||||
|  |         <div className="sticky top-0 z-10 flex-shrink-0 flex h-16 bg-white border-b border-gray-200"> | ||||||
|  |           <div className="flex-1 px-4 flex justify-between"> | ||||||
|  |             <div className="flex-1 flex"> | ||||||
|  |               <div className="w-full flex md:ml-0"> | ||||||
|  |                 <div className="relative w-full text-gray-400 focus-within:text-gray-600"> | ||||||
|  |                   <div className="absolute inset-y-0 left-0 flex items-center pointer-events-none"> | ||||||
|  |                     <Bell className="h-5 w-5" /> | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |             <div className="ml-4 flex items-center md:ml-6"> | ||||||
|  |               <div className="ml-3 relative"> | ||||||
|  |                 <div className="flex items-center space-x-4"> | ||||||
|  |                   <Link | ||||||
|  |                     to="/profile" | ||||||
|  |                     className="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900" | ||||||
|  |                   > | ||||||
|  |                     <User className="h-5 w-5 mr-2" /> | ||||||
|  |                     Profile | ||||||
|  |                   </Link> | ||||||
|  |                   <button | ||||||
|  |                     onClick={handleLogout} | ||||||
|  |                     className="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900" | ||||||
|  |                   > | ||||||
|  |                     <LogOut className="h-5 w-5 mr-2" /> | ||||||
|  |                     Logout | ||||||
|  |                   </button> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Layout; | ||||||
							
								
								
									
										70
									
								
								frontend/src/components/Layout.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								frontend/src/components/Layout.test.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import { render, screen } from '@testing-library/react'; | ||||||
|  | import { BrowserRouter } from 'react-router-dom'; | ||||||
|  | import Layout from './Layout'; | ||||||
|  |  | ||||||
|  | // Mock the AuthContext | ||||||
|  | const mockUseAuth = { | ||||||
|  |   user: { | ||||||
|  |     id: '1', | ||||||
|  |     firstName: 'John', | ||||||
|  |     lastName: 'Doe', | ||||||
|  |     email: 'john@example.com', | ||||||
|  |     role: 'candidate' | ||||||
|  |   }, | ||||||
|  |   logout: jest.fn() | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | jest.mock('../contexts/AuthContext', () => ({ | ||||||
|  |   useAuth: () => mockUseAuth | ||||||
|  | })); | ||||||
|  |  | ||||||
|  | // Mock react-router-dom | ||||||
|  | jest.mock('react-router-dom', () => ({ | ||||||
|  |   ...jest.requireActual('react-router-dom'), | ||||||
|  |   useLocation: () => ({ pathname: '/dashboard' }), | ||||||
|  |   Link: ({ children, to }) => <a href={to}>{children}</a>, | ||||||
|  |   Outlet: () => <div data-testid="outlet">Outlet</div> | ||||||
|  | })); | ||||||
|  |  | ||||||
|  | const renderWithRouter = (component) => { | ||||||
|  |   return render( | ||||||
|  |     <BrowserRouter> | ||||||
|  |       {component} | ||||||
|  |     </BrowserRouter> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | describe('Layout', () => { | ||||||
|  |   it('renders the layout with user information', () => { | ||||||
|  |     renderWithRouter(<Layout />); | ||||||
|  |      | ||||||
|  |     expect(screen.getByText('MysteryApp-Cursor')).toBeInTheDocument(); | ||||||
|  |     expect(screen.getByText('John Doe')).toBeInTheDocument(); | ||||||
|  |     expect(screen.getByText('candidate')).toBeInTheDocument(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('renders navigation items for candidate role', () => { | ||||||
|  |     renderWithRouter(<Layout />); | ||||||
|  |      | ||||||
|  |     expect(screen.getByText('Dashboard')).toBeInTheDocument(); | ||||||
|  |     expect(screen.getByText('Jobs')).toBeInTheDocument(); | ||||||
|  |     expect(screen.getByText('Applications')).toBeInTheDocument(); | ||||||
|  |     expect(screen.getByText('Resumes')).toBeInTheDocument(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('renders logout button', () => { | ||||||
|  |     renderWithRouter(<Layout />); | ||||||
|  |      | ||||||
|  |     expect(screen.getByText('Logout')).toBeInTheDocument(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('calls logout when logout button is clicked', () => { | ||||||
|  |     renderWithRouter(<Layout />); | ||||||
|  |      | ||||||
|  |     const logoutButton = screen.getByText('Logout'); | ||||||
|  |     logoutButton.click(); | ||||||
|  |      | ||||||
|  |     expect(mockUseAuth.logout).toHaveBeenCalled(); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										105
									
								
								frontend/src/contexts/AuthContext.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								frontend/src/contexts/AuthContext.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | |||||||
|  | import React, { createContext, useContext, useState, useEffect } from 'react'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import toast from 'react-hot-toast'; | ||||||
|  |  | ||||||
|  | const AuthContext = createContext(); | ||||||
|  |  | ||||||
|  | export const useAuth = () => { | ||||||
|  |   const context = useContext(AuthContext); | ||||||
|  |   if (!context) { | ||||||
|  |     throw new Error('useAuth must be used within an AuthProvider'); | ||||||
|  |   } | ||||||
|  |   return context; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const AuthProvider = ({ children }) => { | ||||||
|  |   const [user, setUser] = useState(null); | ||||||
|  |   const [loading, setLoading] = useState(true); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     const token = localStorage.getItem('token'); | ||||||
|  |     if (token) { | ||||||
|  |       axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; | ||||||
|  |       fetchUser(); | ||||||
|  |     } else { | ||||||
|  |       setLoading(false); | ||||||
|  |     } | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   const fetchUser = async () => { | ||||||
|  |     try { | ||||||
|  |       const response = await axios.get('/api/auth/me'); | ||||||
|  |       setUser(response.data.user); | ||||||
|  |     } catch (error) { | ||||||
|  |       console.error('Failed to fetch user:', error); | ||||||
|  |       localStorage.removeItem('token'); | ||||||
|  |       delete axios.defaults.headers.common['Authorization']; | ||||||
|  |     } finally { | ||||||
|  |       setLoading(false); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const login = async (email, password) => { | ||||||
|  |     try { | ||||||
|  |       const response = await axios.post('/api/auth/login', { email, password }); | ||||||
|  |       const { token, user } = response.data; | ||||||
|  |        | ||||||
|  |       localStorage.setItem('token', token); | ||||||
|  |       axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; | ||||||
|  |       setUser(user); | ||||||
|  |        | ||||||
|  |       toast.success('Login successful!'); | ||||||
|  |       return { success: true }; | ||||||
|  |     } catch (error) { | ||||||
|  |       const message = error.response?.data?.error || 'Login failed'; | ||||||
|  |       toast.error(message); | ||||||
|  |       return { success: false, error: message }; | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const register = async (userData) => { | ||||||
|  |     try { | ||||||
|  |       const response = await axios.post('/api/auth/register', userData); | ||||||
|  |       const { token, user } = response.data; | ||||||
|  |        | ||||||
|  |       localStorage.setItem('token', token); | ||||||
|  |       axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; | ||||||
|  |       setUser(user); | ||||||
|  |        | ||||||
|  |       toast.success('Registration successful!'); | ||||||
|  |       return { success: true }; | ||||||
|  |     } catch (error) { | ||||||
|  |       const message = error.response?.data?.error || 'Registration failed'; | ||||||
|  |       toast.error(message); | ||||||
|  |       return { success: false, error: message }; | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const logout = async () => { | ||||||
|  |     try { | ||||||
|  |       await axios.post('/api/auth/logout'); | ||||||
|  |     } catch (error) { | ||||||
|  |       console.error('Logout error:', error); | ||||||
|  |     } finally { | ||||||
|  |       localStorage.removeItem('token'); | ||||||
|  |       delete axios.defaults.headers.common['Authorization']; | ||||||
|  |       setUser(null); | ||||||
|  |       toast.success('Logged out successfully'); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const value = { | ||||||
|  |     user, | ||||||
|  |     loading, | ||||||
|  |     login, | ||||||
|  |     register, | ||||||
|  |     logout, | ||||||
|  |     fetchUser | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <AuthContext.Provider value={value}> | ||||||
|  |       {children} | ||||||
|  |     </AuthContext.Provider> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										47
									
								
								frontend/src/index.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								frontend/src/index.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | |||||||
|  | @tailwind base; | ||||||
|  | @tailwind components; | ||||||
|  | @tailwind utilities; | ||||||
|  |  | ||||||
|  | @layer base { | ||||||
|  |   html { | ||||||
|  |     font-family: 'Inter', system-ui, sans-serif; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @layer components { | ||||||
|  |   .btn { | ||||||
|  |     @apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2; | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   .btn-primary { | ||||||
|  |     @apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500; | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   .btn-secondary { | ||||||
|  |     @apply bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500; | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   .btn-danger { | ||||||
|  |     @apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500; | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   .input { | ||||||
|  |     @apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent; | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   .card { | ||||||
|  |     @apply bg-white rounded-lg shadow-md border border-gray-200; | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   .card-header { | ||||||
|  |     @apply px-6 py-4 border-b border-gray-200; | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   .card-body { | ||||||
|  |     @apply px-6 py-4; | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   .card-footer { | ||||||
|  |     @apply px-6 py-4 border-t border-gray-200; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								frontend/src/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								frontend/src/index.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import ReactDOM from 'react-dom/client'; | ||||||
|  | import './index.css'; | ||||||
|  | import App from './App'; | ||||||
|  |  | ||||||
|  | const root = ReactDOM.createRoot(document.getElementById('root')); | ||||||
|  | root.render( | ||||||
|  |   <React.StrictMode> | ||||||
|  |     <App /> | ||||||
|  |   </React.StrictMode> | ||||||
|  | ); | ||||||
							
								
								
									
										111
									
								
								frontend/src/pages/Applications.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								frontend/src/pages/Applications.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,111 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import { useQuery } from 'react-query'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import { FileText, Briefcase, Building, Clock, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; | ||||||
|  |  | ||||||
|  | const Applications = () => { | ||||||
|  |   const { data, isLoading } = useQuery('applications', async () => { | ||||||
|  |     const response = await axios.get('/api/applications'); | ||||||
|  |     return response.data; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const getStatusIcon = (status) => { | ||||||
|  |     switch (status) { | ||||||
|  |       case 'applied': return <Clock className="h-4 w-4" />; | ||||||
|  |       case 'reviewed': return <AlertCircle className="h-4 w-4" />; | ||||||
|  |       case 'shortlisted': return <CheckCircle className="h-4 w-4" />; | ||||||
|  |       case 'interviewed': return <CheckCircle className="h-4 w-4" />; | ||||||
|  |       case 'offered': return <CheckCircle className="h-4 w-4" />; | ||||||
|  |       case 'rejected': return <XCircle className="h-4 w-4" />; | ||||||
|  |       default: return <Clock className="h-4 w-4" />; | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const getStatusColor = (status) => { | ||||||
|  |     switch (status) { | ||||||
|  |       case 'applied': return 'bg-blue-100 text-blue-800'; | ||||||
|  |       case 'reviewed': return 'bg-yellow-100 text-yellow-800'; | ||||||
|  |       case 'shortlisted': return 'bg-green-100 text-green-800'; | ||||||
|  |       case 'interviewed': return 'bg-green-100 text-green-800'; | ||||||
|  |       case 'offered': return 'bg-green-100 text-green-800'; | ||||||
|  |       case 'rejected': return 'bg-red-100 text-red-800'; | ||||||
|  |       case 'withdrawn': return 'bg-gray-100 text-gray-800'; | ||||||
|  |       default: return 'bg-gray-100 text-gray-800'; | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   if (isLoading) { | ||||||
|  |     return ( | ||||||
|  |       <div className="flex items-center justify-center h-64"> | ||||||
|  |         <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="space-y-6"> | ||||||
|  |       <div> | ||||||
|  |         <h1 className="text-2xl font-bold text-gray-900">Applications</h1> | ||||||
|  |         <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |           Track your job applications and their status | ||||||
|  |         </p> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div className="space-y-4"> | ||||||
|  |         {data?.applications?.length > 0 ? ( | ||||||
|  |           data.applications.map((application) => ( | ||||||
|  |             <div key={application.id} className="bg-white shadow rounded-lg"> | ||||||
|  |               <div className="px-4 py-5 sm:p-6"> | ||||||
|  |                 <div className="flex items-start justify-between"> | ||||||
|  |                   <div className="flex-1"> | ||||||
|  |                     <div className="flex items-center"> | ||||||
|  |                       <h3 className="text-lg font-medium text-gray-900"> | ||||||
|  |                         {application.job_title} | ||||||
|  |                       </h3> | ||||||
|  |                       <span className={`ml-3 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(application.status)}`}> | ||||||
|  |                         {getStatusIcon(application.status)} | ||||||
|  |                         <span className="ml-1 capitalize">{application.status}</span> | ||||||
|  |                       </span> | ||||||
|  |                     </div> | ||||||
|  |                     <div className="mt-1 flex items-center text-sm text-gray-500"> | ||||||
|  |                       <Building className="h-4 w-4 mr-1" /> | ||||||
|  |                       {application.company_name} | ||||||
|  |                     </div> | ||||||
|  |                     <div className="mt-2 flex items-center text-sm text-gray-500"> | ||||||
|  |                       <Clock className="h-4 w-4 mr-1" /> | ||||||
|  |                       Applied on {new Date(application.applied_at).toLocaleDateString()} | ||||||
|  |                     </div> | ||||||
|  |                     {application.cover_letter && ( | ||||||
|  |                       <div className="mt-3"> | ||||||
|  |                         <p className="text-sm text-gray-600 line-clamp-2"> | ||||||
|  |                           {application.cover_letter} | ||||||
|  |                         </p> | ||||||
|  |                       </div> | ||||||
|  |                     )} | ||||||
|  |                     {application.notes && ( | ||||||
|  |                       <div className="mt-3"> | ||||||
|  |                         <p className="text-sm text-gray-600"> | ||||||
|  |                           <strong>Notes:</strong> {application.notes} | ||||||
|  |                         </p> | ||||||
|  |                       </div> | ||||||
|  |                     )} | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           )) | ||||||
|  |         ) : ( | ||||||
|  |           <div className="text-center py-12"> | ||||||
|  |             <FileText className="mx-auto h-12 w-12 text-gray-400" /> | ||||||
|  |             <h3 className="mt-2 text-sm font-medium text-gray-900">No applications found</h3> | ||||||
|  |             <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |               You haven't applied to any jobs yet. | ||||||
|  |             </p> | ||||||
|  |           </div> | ||||||
|  |         )} | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Applications; | ||||||
							
								
								
									
										149
									
								
								frontend/src/pages/CandidateDetails.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								frontend/src/pages/CandidateDetails.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,149 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import { useParams } from 'react-router-dom'; | ||||||
|  | import { useQuery } from 'react-query'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import { MapPin, Mail, Phone, Linkedin, Github, Globe, User } from 'lucide-react'; | ||||||
|  |  | ||||||
|  | const CandidateDetails = () => { | ||||||
|  |   const { id } = useParams(); | ||||||
|  |  | ||||||
|  |   const { data: candidate, isLoading } = useQuery(['candidate', id], async () => { | ||||||
|  |     const response = await axios.get(`/api/candidates/${id}`); | ||||||
|  |     return response.data; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   if (isLoading) { | ||||||
|  |     return ( | ||||||
|  |       <div className="flex items-center justify-center h-64"> | ||||||
|  |         <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (!candidate) { | ||||||
|  |     return ( | ||||||
|  |       <div className="text-center py-12"> | ||||||
|  |         <h3 className="text-lg font-medium text-gray-900">Candidate not found</h3> | ||||||
|  |         <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |           The candidate you're looking for doesn't exist. | ||||||
|  |         </p> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="space-y-6"> | ||||||
|  |       <div className="bg-white shadow rounded-lg"> | ||||||
|  |         <div className="px-4 py-5 sm:p-6"> | ||||||
|  |           <div className="flex items-start"> | ||||||
|  |             <div className="flex-shrink-0"> | ||||||
|  |               <div className="h-16 w-16 rounded-full bg-primary-100 flex items-center justify-center"> | ||||||
|  |                 <User className="h-8 w-8 text-primary-600" /> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |             <div className="ml-6 flex-1"> | ||||||
|  |               <h1 className="text-2xl font-bold text-gray-900"> | ||||||
|  |                 {candidate.first_name} {candidate.last_name} | ||||||
|  |               </h1> | ||||||
|  |               <p className="text-lg text-gray-600">{candidate.email}</p> | ||||||
|  |                | ||||||
|  |               <div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2"> | ||||||
|  |                 {candidate.location && ( | ||||||
|  |                   <div className="flex items-center text-sm text-gray-500"> | ||||||
|  |                     <MapPin className="h-4 w-4 mr-2" /> | ||||||
|  |                     {candidate.location} | ||||||
|  |                   </div> | ||||||
|  |                 )} | ||||||
|  |                  | ||||||
|  |                 {candidate.phone && ( | ||||||
|  |                   <div className="flex items-center text-sm text-gray-500"> | ||||||
|  |                     <Phone className="h-4 w-4 mr-2" /> | ||||||
|  |                     {candidate.phone} | ||||||
|  |                   </div> | ||||||
|  |                 )} | ||||||
|  |                  | ||||||
|  |                 {candidate.linkedin_url && ( | ||||||
|  |                   <div className="flex items-center text-sm text-gray-500"> | ||||||
|  |                     <Linkedin className="h-4 w-4 mr-2" /> | ||||||
|  |                     <a href={candidate.linkedin_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500"> | ||||||
|  |                       LinkedIn Profile | ||||||
|  |                     </a> | ||||||
|  |                   </div> | ||||||
|  |                 )} | ||||||
|  |                  | ||||||
|  |                 {candidate.github_url && ( | ||||||
|  |                   <div className="flex items-center text-sm text-gray-500"> | ||||||
|  |                     <Github className="h-4 w-4 mr-2" /> | ||||||
|  |                     <a href={candidate.github_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500"> | ||||||
|  |                       GitHub Profile | ||||||
|  |                     </a> | ||||||
|  |                   </div> | ||||||
|  |                 )} | ||||||
|  |                  | ||||||
|  |                 {candidate.portfolio_url && ( | ||||||
|  |                   <div className="flex items-center text-sm text-gray-500"> | ||||||
|  |                     <Globe className="h-4 w-4 mr-2" /> | ||||||
|  |                     <a href={candidate.portfolio_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500"> | ||||||
|  |                       Portfolio | ||||||
|  |                     </a> | ||||||
|  |                   </div> | ||||||
|  |                 )} | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {candidate.bio && ( | ||||||
|  |         <div className="bg-white shadow rounded-lg"> | ||||||
|  |           <div className="px-4 py-5 sm:p-6"> | ||||||
|  |             <h2 className="text-lg font-medium text-gray-900 mb-4">About</h2> | ||||||
|  |             <p className="text-gray-600 whitespace-pre-wrap">{candidate.bio}</p> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  |  | ||||||
|  |       {candidate.skills && candidate.skills.length > 0 && ( | ||||||
|  |         <div className="bg-white shadow rounded-lg"> | ||||||
|  |           <div className="px-4 py-5 sm:p-6"> | ||||||
|  |             <h2 className="text-lg font-medium text-gray-900 mb-4">Skills</h2> | ||||||
|  |             <div className="flex flex-wrap gap-2"> | ||||||
|  |               {candidate.skills.map((skill, index) => ( | ||||||
|  |                 <span | ||||||
|  |                   key={index} | ||||||
|  |                   className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800" | ||||||
|  |                 > | ||||||
|  |                   {skill} | ||||||
|  |                 </span> | ||||||
|  |               ))} | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  |  | ||||||
|  |       <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> | ||||||
|  |         <div className="bg-white shadow rounded-lg"> | ||||||
|  |           <div className="px-4 py-5 sm:p-6"> | ||||||
|  |             <h2 className="text-lg font-medium text-gray-900 mb-4">Experience Level</h2> | ||||||
|  |             <span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800"> | ||||||
|  |               {candidate.experience_level || 'Not specified'} | ||||||
|  |             </span> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         {candidate.salary_expectation && ( | ||||||
|  |           <div className="bg-white shadow rounded-lg"> | ||||||
|  |             <div className="px-4 py-5 sm:p-6"> | ||||||
|  |               <h2 className="text-lg font-medium text-gray-900 mb-4">Salary Expectation</h2> | ||||||
|  |               <p className="text-lg font-medium text-gray-900"> | ||||||
|  |                 ${candidate.salary_expectation.toLocaleString()} | ||||||
|  |               </p> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         )} | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default CandidateDetails; | ||||||
							
								
								
									
										277
									
								
								frontend/src/pages/Candidates.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										277
									
								
								frontend/src/pages/Candidates.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,277 @@ | |||||||
|  | import React, { useState } from 'react'; | ||||||
|  | import { Link } from 'react-router-dom'; | ||||||
|  | import { useQuery } from 'react-query'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import { Search, MapPin, User, Star } from 'lucide-react'; | ||||||
|  |  | ||||||
|  | const Candidates = () => { | ||||||
|  |   const [filters, setFilters] = useState({ | ||||||
|  |     search: '', | ||||||
|  |     location: '', | ||||||
|  |     experienceLevel: '', | ||||||
|  |     skills: '' | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const { data, isLoading, refetch } = useQuery(['candidates', filters], async () => { | ||||||
|  |     const params = new URLSearchParams(); | ||||||
|  |     Object.entries(filters).forEach(([key, value]) => { | ||||||
|  |       if (value) params.append(key, value); | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     const response = await axios.get(`/api/candidates?${params.toString()}`); | ||||||
|  |     return response.data; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const handleFilterChange = (e) => { | ||||||
|  |     setFilters({ | ||||||
|  |       ...filters, | ||||||
|  |       [e.target.name]: e.target.value | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleSearch = (e) => { | ||||||
|  |     e.preventDefault(); | ||||||
|  |     refetch(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const getExperienceColor = (level) => { | ||||||
|  |     switch (level) { | ||||||
|  |       case 'entry': return 'bg-green-100 text-green-800'; | ||||||
|  |       case 'mid': return 'bg-blue-100 text-blue-800'; | ||||||
|  |       case 'senior': return 'bg-purple-100 text-purple-800'; | ||||||
|  |       case 'lead': return 'bg-orange-100 text-orange-800'; | ||||||
|  |       case 'executive': return 'bg-red-100 text-red-800'; | ||||||
|  |       default: return 'bg-gray-100 text-gray-800'; | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   if (isLoading) { | ||||||
|  |     return ( | ||||||
|  |       <div className="flex items-center justify-center h-64"> | ||||||
|  |         <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="space-y-6"> | ||||||
|  |       <div> | ||||||
|  |         <h1 className="text-2xl font-bold text-gray-900">Candidates</h1> | ||||||
|  |         <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |           Browse and discover talented candidates | ||||||
|  |         </p> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {/* Filters */} | ||||||
|  |       <div className="bg-white shadow rounded-lg"> | ||||||
|  |         <div className="px-4 py-5 sm:p-6"> | ||||||
|  |           <form onSubmit={handleSearch} className="space-y-4"> | ||||||
|  |             <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="search" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Search | ||||||
|  |                 </label> | ||||||
|  |                 <div className="mt-1 relative"> | ||||||
|  |                   <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||||||
|  |                     <Search className="h-5 w-5 text-gray-400" /> | ||||||
|  |                   </div> | ||||||
|  |                   <input | ||||||
|  |                     type="text" | ||||||
|  |                     name="search" | ||||||
|  |                     id="search" | ||||||
|  |                     className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm" | ||||||
|  |                     placeholder="Name or keywords" | ||||||
|  |                     value={filters.search} | ||||||
|  |                     onChange={handleFilterChange} | ||||||
|  |                   /> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="location" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Location | ||||||
|  |                 </label> | ||||||
|  |                 <div className="mt-1 relative"> | ||||||
|  |                   <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||||||
|  |                     <MapPin className="h-5 w-5 text-gray-400" /> | ||||||
|  |                   </div> | ||||||
|  |                   <input | ||||||
|  |                     type="text" | ||||||
|  |                     name="location" | ||||||
|  |                     id="location" | ||||||
|  |                     className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm" | ||||||
|  |                     placeholder="City, state, or country" | ||||||
|  |                     value={filters.location} | ||||||
|  |                     onChange={handleFilterChange} | ||||||
|  |                   /> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="experienceLevel" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Experience Level | ||||||
|  |                 </label> | ||||||
|  |                 <select | ||||||
|  |                   id="experienceLevel" | ||||||
|  |                   name="experienceLevel" | ||||||
|  |                   className="mt-1 block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md" | ||||||
|  |                   value={filters.experienceLevel} | ||||||
|  |                   onChange={handleFilterChange} | ||||||
|  |                 > | ||||||
|  |                   <option value="">All Levels</option> | ||||||
|  |                   <option value="entry">Entry Level</option> | ||||||
|  |                   <option value="mid">Mid Level</option> | ||||||
|  |                   <option value="senior">Senior Level</option> | ||||||
|  |                   <option value="lead">Lead</option> | ||||||
|  |                   <option value="executive">Executive</option> | ||||||
|  |                 </select> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="skills" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Skills | ||||||
|  |                 </label> | ||||||
|  |                 <input | ||||||
|  |                   type="text" | ||||||
|  |                   name="skills" | ||||||
|  |                   id="skills" | ||||||
|  |                   className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm" | ||||||
|  |                   placeholder="JavaScript, React, etc." | ||||||
|  |                   value={filters.skills} | ||||||
|  |                   onChange={handleFilterChange} | ||||||
|  |                 /> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div className="flex justify-end"> | ||||||
|  |               <button | ||||||
|  |                 type="submit" | ||||||
|  |                 className="btn btn-primary" | ||||||
|  |               > | ||||||
|  |                 Search Candidates | ||||||
|  |               </button> | ||||||
|  |             </div> | ||||||
|  |           </form> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {/* Candidates List */} | ||||||
|  |       <div className="grid grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3"> | ||||||
|  |         {data?.candidates?.length > 0 ? ( | ||||||
|  |           data.candidates.map((candidate) => ( | ||||||
|  |             <div key={candidate.id} className="bg-white shadow rounded-lg"> | ||||||
|  |               <div className="px-4 py-5 sm:p-6"> | ||||||
|  |                 <div className="flex items-start"> | ||||||
|  |                   <div className="flex-shrink-0"> | ||||||
|  |                     <div className="h-12 w-12 rounded-full bg-primary-100 flex items-center justify-center"> | ||||||
|  |                       <User className="h-6 w-6 text-primary-600" /> | ||||||
|  |                     </div> | ||||||
|  |                   </div> | ||||||
|  |                   <div className="ml-4 flex-1"> | ||||||
|  |                     <h3 className="text-lg font-medium text-gray-900"> | ||||||
|  |                       <Link to={`/candidates/${candidate.id}`} className="hover:text-primary-600"> | ||||||
|  |                         {candidate.first_name} {candidate.last_name} | ||||||
|  |                       </Link> | ||||||
|  |                     </h3> | ||||||
|  |                     <p className="text-sm text-gray-500">{candidate.email}</p> | ||||||
|  |                      | ||||||
|  |                     {candidate.location && ( | ||||||
|  |                       <div className="mt-2 flex items-center text-sm text-gray-500"> | ||||||
|  |                         <MapPin className="h-4 w-4 mr-1" /> | ||||||
|  |                         {candidate.location} | ||||||
|  |                       </div> | ||||||
|  |                     )} | ||||||
|  |  | ||||||
|  |                     {candidate.experience_level && ( | ||||||
|  |                       <div className="mt-2"> | ||||||
|  |                         <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getExperienceColor(candidate.experience_level)}`}> | ||||||
|  |                           {candidate.experience_level} level | ||||||
|  |                         </span> | ||||||
|  |                       </div> | ||||||
|  |                     )} | ||||||
|  |  | ||||||
|  |                     {candidate.bio && ( | ||||||
|  |                       <div className="mt-3"> | ||||||
|  |                         <p className="text-sm text-gray-600 line-clamp-3"> | ||||||
|  |                           {candidate.bio} | ||||||
|  |                         </p> | ||||||
|  |                       </div> | ||||||
|  |                     )} | ||||||
|  |  | ||||||
|  |                     {candidate.skills && candidate.skills.length > 0 && ( | ||||||
|  |                       <div className="mt-3"> | ||||||
|  |                         <div className="flex flex-wrap gap-1"> | ||||||
|  |                           {candidate.skills.slice(0, 4).map((skill, index) => ( | ||||||
|  |                             <span | ||||||
|  |                               key={index} | ||||||
|  |                               className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800" | ||||||
|  |                             > | ||||||
|  |                               {skill} | ||||||
|  |                             </span> | ||||||
|  |                           ))} | ||||||
|  |                           {candidate.skills.length > 4 && ( | ||||||
|  |                             <span className="text-xs text-gray-500"> | ||||||
|  |                               +{candidate.skills.length - 4} more | ||||||
|  |                             </span> | ||||||
|  |                           )} | ||||||
|  |                         </div> | ||||||
|  |                       </div> | ||||||
|  |                     )} | ||||||
|  |  | ||||||
|  |                     {candidate.salary_expectation && ( | ||||||
|  |                       <div className="mt-3 flex items-center text-sm text-gray-500"> | ||||||
|  |                         <Star className="h-4 w-4 mr-1" /> | ||||||
|  |                         Expected: ${candidate.salary_expectation?.toLocaleString()} | ||||||
|  |                       </div> | ||||||
|  |                     )} | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           )) | ||||||
|  |         ) : ( | ||||||
|  |           <div className="col-span-full text-center py-12"> | ||||||
|  |             <User className="mx-auto h-12 w-12 text-gray-400" /> | ||||||
|  |             <h3 className="mt-2 text-sm font-medium text-gray-900">No candidates found</h3> | ||||||
|  |             <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |               Try adjusting your search criteria | ||||||
|  |             </p> | ||||||
|  |           </div> | ||||||
|  |         )} | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {/* Pagination */} | ||||||
|  |       {data?.pagination && data.pagination.pages > 1 && ( | ||||||
|  |         <div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6"> | ||||||
|  |           <div className="flex-1 flex justify-between sm:hidden"> | ||||||
|  |             <button className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"> | ||||||
|  |               Previous | ||||||
|  |             </button> | ||||||
|  |             <button className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"> | ||||||
|  |               Next | ||||||
|  |             </button> | ||||||
|  |           </div> | ||||||
|  |           <div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> | ||||||
|  |             <div> | ||||||
|  |               <p className="text-sm text-gray-700"> | ||||||
|  |                 Showing{' '} | ||||||
|  |                 <span className="font-medium"> | ||||||
|  |                   {((data.pagination.page - 1) * data.pagination.limit) + 1} | ||||||
|  |                 </span>{' '} | ||||||
|  |                 to{' '} | ||||||
|  |                 <span className="font-medium"> | ||||||
|  |                   {Math.min(data.pagination.page * data.pagination.limit, data.pagination.total)} | ||||||
|  |                 </span>{' '} | ||||||
|  |                 of{' '} | ||||||
|  |                 <span className="font-medium">{data.pagination.total}</span>{' '} | ||||||
|  |                 results | ||||||
|  |               </p> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Candidates; | ||||||
							
								
								
									
										464
									
								
								frontend/src/pages/CreateJob.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										464
									
								
								frontend/src/pages/CreateJob.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,464 @@ | |||||||
|  | import React, { useState } from 'react'; | ||||||
|  | import { useNavigate } from 'react-router-dom'; | ||||||
|  | import { useAuth } from '../contexts/AuthContext'; | ||||||
|  | import { useMutation } from 'react-query'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import toast from 'react-hot-toast'; | ||||||
|  | import { ArrowLeft, Save, Plus, X } from 'lucide-react'; | ||||||
|  |  | ||||||
|  | const CreateJob = () => { | ||||||
|  |   const { user } = useAuth(); | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |   const [formData, setFormData] = useState({ | ||||||
|  |     title: '', | ||||||
|  |     description: '', | ||||||
|  |     requirements: [''], | ||||||
|  |     responsibilities: [''], | ||||||
|  |     location: '', | ||||||
|  |     employmentType: 'full-time', | ||||||
|  |     salaryMin: '', | ||||||
|  |     salaryMax: '', | ||||||
|  |     currency: 'USD', | ||||||
|  |     remoteAllowed: false, | ||||||
|  |     experienceLevel: '', | ||||||
|  |     skillsRequired: [''], | ||||||
|  |     benefits: [''], | ||||||
|  |     applicationDeadline: '' | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const createJobMutation = useMutation(async (jobData) => { | ||||||
|  |     const response = await axios.post('/api/jobs', jobData); | ||||||
|  |     return response.data; | ||||||
|  |   }, { | ||||||
|  |     onSuccess: () => { | ||||||
|  |       toast.success('Job posted successfully!'); | ||||||
|  |       navigate('/jobs'); | ||||||
|  |     }, | ||||||
|  |     onError: (error) => { | ||||||
|  |       toast.error(error.response?.data?.error || 'Failed to create job'); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const handleChange = (e) => { | ||||||
|  |     const { name, value, type, checked } = e.target; | ||||||
|  |     setFormData(prev => ({ | ||||||
|  |       ...prev, | ||||||
|  |       [name]: type === 'checkbox' ? checked : value | ||||||
|  |     })); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleArrayChange = (field, index, value) => { | ||||||
|  |     setFormData(prev => ({ | ||||||
|  |       ...prev, | ||||||
|  |       [field]: prev[field].map((item, i) => i === index ? value : item) | ||||||
|  |     })); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const addArrayItem = (field) => { | ||||||
|  |     setFormData(prev => ({ | ||||||
|  |       ...prev, | ||||||
|  |       [field]: [...prev[field], ''] | ||||||
|  |     })); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const removeArrayItem = (field, index) => { | ||||||
|  |     setFormData(prev => ({ | ||||||
|  |       ...prev, | ||||||
|  |       [field]: prev[field].filter((_, i) => i !== index) | ||||||
|  |     })); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleSubmit = (e) => { | ||||||
|  |     e.preventDefault(); | ||||||
|  |      | ||||||
|  |     // Filter out empty array items | ||||||
|  |     const cleanedData = { | ||||||
|  |       ...formData, | ||||||
|  |       requirements: formData.requirements.filter(req => req.trim()), | ||||||
|  |       responsibilities: formData.responsibilities.filter(resp => resp.trim()), | ||||||
|  |       skillsRequired: formData.skillsRequired.filter(skill => skill.trim()), | ||||||
|  |       benefits: formData.benefits.filter(benefit => benefit.trim()), | ||||||
|  |       salaryMin: formData.salaryMin ? parseInt(formData.salaryMin) : undefined, | ||||||
|  |       salaryMax: formData.salaryMax ? parseInt(formData.salaryMax) : undefined, | ||||||
|  |       applicationDeadline: formData.applicationDeadline || undefined | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     createJobMutation.mutate(cleanedData); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="space-y-6"> | ||||||
|  |       <div className="flex items-center"> | ||||||
|  |         <button | ||||||
|  |           onClick={() => navigate('/jobs')} | ||||||
|  |           className="mr-4 p-2 text-gray-400 hover:text-gray-600" | ||||||
|  |         > | ||||||
|  |           <ArrowLeft className="h-5 w-5" /> | ||||||
|  |         </button> | ||||||
|  |         <div> | ||||||
|  |           <h1 className="text-2xl font-bold text-gray-900">Post a New Job</h1> | ||||||
|  |           <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |             Create a job posting to attract qualified candidates | ||||||
|  |           </p> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <form onSubmit={handleSubmit} className="space-y-6"> | ||||||
|  |         {/* Basic Information */} | ||||||
|  |         <div className="bg-white shadow rounded-lg"> | ||||||
|  |           <div className="px-4 py-5 sm:p-6"> | ||||||
|  |             <h3 className="text-lg font-medium text-gray-900 mb-4">Basic Information</h3> | ||||||
|  |             <div className="grid grid-cols-1 gap-6 sm:grid-cols-2"> | ||||||
|  |               <div className="sm:col-span-2"> | ||||||
|  |                 <label htmlFor="title" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Job Title * | ||||||
|  |                 </label> | ||||||
|  |                 <input | ||||||
|  |                   type="text" | ||||||
|  |                   name="title" | ||||||
|  |                   id="title" | ||||||
|  |                   required | ||||||
|  |                   className="mt-1 input" | ||||||
|  |                   value={formData.title} | ||||||
|  |                   onChange={handleChange} | ||||||
|  |                   placeholder="e.g., Senior Software Engineer" | ||||||
|  |                 /> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div className="sm:col-span-2"> | ||||||
|  |                 <label htmlFor="description" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Job Description * | ||||||
|  |                 </label> | ||||||
|  |                 <textarea | ||||||
|  |                   name="description" | ||||||
|  |                   id="description" | ||||||
|  |                   rows={4} | ||||||
|  |                   required | ||||||
|  |                   className="mt-1 input" | ||||||
|  |                   value={formData.description} | ||||||
|  |                   onChange={handleChange} | ||||||
|  |                   placeholder="Describe the role, company culture, and what makes this opportunity special..." | ||||||
|  |                 /> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="location" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Location * | ||||||
|  |                 </label> | ||||||
|  |                 <input | ||||||
|  |                   type="text" | ||||||
|  |                   name="location" | ||||||
|  |                   id="location" | ||||||
|  |                   required | ||||||
|  |                   className="mt-1 input" | ||||||
|  |                   value={formData.location} | ||||||
|  |                   onChange={handleChange} | ||||||
|  |                   placeholder="e.g., San Francisco, CA" | ||||||
|  |                 /> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="employmentType" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Employment Type * | ||||||
|  |                 </label> | ||||||
|  |                 <select | ||||||
|  |                   name="employmentType" | ||||||
|  |                   id="employmentType" | ||||||
|  |                   required | ||||||
|  |                   className="mt-1 input" | ||||||
|  |                   value={formData.employmentType} | ||||||
|  |                   onChange={handleChange} | ||||||
|  |                 > | ||||||
|  |                   <option value="full-time">Full-time</option> | ||||||
|  |                   <option value="part-time">Part-time</option> | ||||||
|  |                   <option value="contract">Contract</option> | ||||||
|  |                   <option value="internship">Internship</option> | ||||||
|  |                 </select> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         {/* Requirements and Responsibilities */} | ||||||
|  |         <div className="bg-white shadow rounded-lg"> | ||||||
|  |           <div className="px-4 py-5 sm:p-6"> | ||||||
|  |             <h3 className="text-lg font-medium text-gray-900 mb-4">Requirements & Responsibilities</h3> | ||||||
|  |              | ||||||
|  |             <div className="space-y-6"> | ||||||
|  |               <div> | ||||||
|  |                 <label className="block text-sm font-medium text-gray-700 mb-2"> | ||||||
|  |                   Requirements | ||||||
|  |                 </label> | ||||||
|  |                 {formData.requirements.map((req, index) => ( | ||||||
|  |                   <div key={index} className="flex items-center mb-2"> | ||||||
|  |                     <input | ||||||
|  |                       type="text" | ||||||
|  |                       className="input flex-1" | ||||||
|  |                       value={req} | ||||||
|  |                       onChange={(e) => handleArrayChange('requirements', index, e.target.value)} | ||||||
|  |                       placeholder="e.g., 5+ years of experience in React" | ||||||
|  |                     /> | ||||||
|  |                     {formData.requirements.length > 1 && ( | ||||||
|  |                       <button | ||||||
|  |                         type="button" | ||||||
|  |                         onClick={() => removeArrayItem('requirements', index)} | ||||||
|  |                         className="ml-2 p-2 text-red-600 hover:text-red-800" | ||||||
|  |                       > | ||||||
|  |                         <X className="h-4 w-4" /> | ||||||
|  |                       </button> | ||||||
|  |                     )} | ||||||
|  |                   </div> | ||||||
|  |                 ))} | ||||||
|  |                 <button | ||||||
|  |                   type="button" | ||||||
|  |                   onClick={() => addArrayItem('requirements')} | ||||||
|  |                   className="btn btn-secondary text-sm" | ||||||
|  |                 > | ||||||
|  |                   <Plus className="h-4 w-4 mr-1" /> | ||||||
|  |                   Add Requirement | ||||||
|  |                 </button> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div> | ||||||
|  |                 <label className="block text-sm font-medium text-gray-700 mb-2"> | ||||||
|  |                   Responsibilities | ||||||
|  |                 </label> | ||||||
|  |                 {formData.responsibilities.map((resp, index) => ( | ||||||
|  |                   <div key={index} className="flex items-center mb-2"> | ||||||
|  |                     <input | ||||||
|  |                       type="text" | ||||||
|  |                       className="input flex-1" | ||||||
|  |                       value={resp} | ||||||
|  |                       onChange={(e) => handleArrayChange('responsibilities', index, e.target.value)} | ||||||
|  |                       placeholder="e.g., Develop and maintain web applications" | ||||||
|  |                     /> | ||||||
|  |                     {formData.responsibilities.length > 1 && ( | ||||||
|  |                       <button | ||||||
|  |                         type="button" | ||||||
|  |                         onClick={() => removeArrayItem('responsibilities', index)} | ||||||
|  |                         className="ml-2 p-2 text-red-600 hover:text-red-800" | ||||||
|  |                       > | ||||||
|  |                         <X className="h-4 w-4" /> | ||||||
|  |                       </button> | ||||||
|  |                     )} | ||||||
|  |                   </div> | ||||||
|  |                 ))} | ||||||
|  |                 <button | ||||||
|  |                   type="button" | ||||||
|  |                   onClick={() => addArrayItem('responsibilities')} | ||||||
|  |                   className="btn btn-secondary text-sm" | ||||||
|  |                 > | ||||||
|  |                   <Plus className="h-4 w-4 mr-1" /> | ||||||
|  |                   Add Responsibility | ||||||
|  |                 </button> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         {/* Compensation */} | ||||||
|  |         <div className="bg-white shadow rounded-lg"> | ||||||
|  |           <div className="px-4 py-5 sm:p-6"> | ||||||
|  |             <h3 className="text-lg font-medium text-gray-900 mb-4">Compensation</h3> | ||||||
|  |             <div className="grid grid-cols-1 gap-6 sm:grid-cols-3"> | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="salaryMin" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Minimum Salary | ||||||
|  |                 </label> | ||||||
|  |                 <input | ||||||
|  |                   type="number" | ||||||
|  |                   name="salaryMin" | ||||||
|  |                   id="salaryMin" | ||||||
|  |                   className="mt-1 input" | ||||||
|  |                   value={formData.salaryMin} | ||||||
|  |                   onChange={handleChange} | ||||||
|  |                   placeholder="e.g., 80000" | ||||||
|  |                 /> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="salaryMax" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Maximum Salary | ||||||
|  |                 </label> | ||||||
|  |                 <input | ||||||
|  |                   type="number" | ||||||
|  |                   name="salaryMax" | ||||||
|  |                   id="salaryMax" | ||||||
|  |                   className="mt-1 input" | ||||||
|  |                   value={formData.salaryMax} | ||||||
|  |                   onChange={handleChange} | ||||||
|  |                   placeholder="e.g., 120000" | ||||||
|  |                 /> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="currency" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Currency | ||||||
|  |                 </label> | ||||||
|  |                 <select | ||||||
|  |                   name="currency" | ||||||
|  |                   id="currency" | ||||||
|  |                   className="mt-1 input" | ||||||
|  |                   value={formData.currency} | ||||||
|  |                   onChange={handleChange} | ||||||
|  |                 > | ||||||
|  |                   <option value="USD">USD</option> | ||||||
|  |                   <option value="EUR">EUR</option> | ||||||
|  |                   <option value="GBP">GBP</option> | ||||||
|  |                   <option value="CAD">CAD</option> | ||||||
|  |                 </select> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         {/* Additional Details */} | ||||||
|  |         <div className="bg-white shadow rounded-lg"> | ||||||
|  |           <div className="px-4 py-5 sm:p-6"> | ||||||
|  |             <h3 className="text-lg font-medium text-gray-900 mb-4">Additional Details</h3> | ||||||
|  |             <div className="space-y-6"> | ||||||
|  |               <div className="grid grid-cols-1 gap-6 sm:grid-cols-2"> | ||||||
|  |                 <div> | ||||||
|  |                   <label htmlFor="experienceLevel" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                     Experience Level | ||||||
|  |                   </label> | ||||||
|  |                   <select | ||||||
|  |                     name="experienceLevel" | ||||||
|  |                     id="experienceLevel" | ||||||
|  |                     className="mt-1 input" | ||||||
|  |                     value={formData.experienceLevel} | ||||||
|  |                     onChange={handleChange} | ||||||
|  |                   > | ||||||
|  |                     <option value="">Select level</option> | ||||||
|  |                     <option value="entry">Entry Level</option> | ||||||
|  |                     <option value="mid">Mid Level</option> | ||||||
|  |                     <option value="senior">Senior Level</option> | ||||||
|  |                     <option value="lead">Lead</option> | ||||||
|  |                     <option value="executive">Executive</option> | ||||||
|  |                   </select> | ||||||
|  |                 </div> | ||||||
|  |  | ||||||
|  |                 <div> | ||||||
|  |                   <label htmlFor="applicationDeadline" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                     Application Deadline | ||||||
|  |                   </label> | ||||||
|  |                   <input | ||||||
|  |                     type="date" | ||||||
|  |                     name="applicationDeadline" | ||||||
|  |                     id="applicationDeadline" | ||||||
|  |                     className="mt-1 input" | ||||||
|  |                     value={formData.applicationDeadline} | ||||||
|  |                     onChange={handleChange} | ||||||
|  |                   /> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div className="flex items-center"> | ||||||
|  |                 <input | ||||||
|  |                   type="checkbox" | ||||||
|  |                   name="remoteAllowed" | ||||||
|  |                   id="remoteAllowed" | ||||||
|  |                   className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded" | ||||||
|  |                   checked={formData.remoteAllowed} | ||||||
|  |                   onChange={handleChange} | ||||||
|  |                 /> | ||||||
|  |                 <label htmlFor="remoteAllowed" className="ml-2 block text-sm text-gray-900"> | ||||||
|  |                   Remote work allowed | ||||||
|  |                 </label> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div> | ||||||
|  |                 <label className="block text-sm font-medium text-gray-700 mb-2"> | ||||||
|  |                   Required Skills | ||||||
|  |                 </label> | ||||||
|  |                 {formData.skillsRequired.map((skill, index) => ( | ||||||
|  |                   <div key={index} className="flex items-center mb-2"> | ||||||
|  |                     <input | ||||||
|  |                       type="text" | ||||||
|  |                       className="input flex-1" | ||||||
|  |                       value={skill} | ||||||
|  |                       onChange={(e) => handleArrayChange('skillsRequired', index, e.target.value)} | ||||||
|  |                       placeholder="e.g., JavaScript, React, Node.js" | ||||||
|  |                     /> | ||||||
|  |                     {formData.skillsRequired.length > 1 && ( | ||||||
|  |                       <button | ||||||
|  |                         type="button" | ||||||
|  |                         onClick={() => removeArrayItem('skillsRequired', index)} | ||||||
|  |                         className="ml-2 p-2 text-red-600 hover:text-red-800" | ||||||
|  |                       > | ||||||
|  |                         <X className="h-4 w-4" /> | ||||||
|  |                       </button> | ||||||
|  |                     )} | ||||||
|  |                   </div> | ||||||
|  |                 ))} | ||||||
|  |                 <button | ||||||
|  |                   type="button" | ||||||
|  |                   onClick={() => addArrayItem('skillsRequired')} | ||||||
|  |                   className="btn btn-secondary text-sm" | ||||||
|  |                 > | ||||||
|  |                   <Plus className="h-4 w-4 mr-1" /> | ||||||
|  |                   Add Skill | ||||||
|  |                 </button> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div> | ||||||
|  |                 <label className="block text-sm font-medium text-gray-700 mb-2"> | ||||||
|  |                   Benefits | ||||||
|  |                 </label> | ||||||
|  |                 {formData.benefits.map((benefit, index) => ( | ||||||
|  |                   <div key={index} className="flex items-center mb-2"> | ||||||
|  |                     <input | ||||||
|  |                       type="text" | ||||||
|  |                       className="input flex-1" | ||||||
|  |                       value={benefit} | ||||||
|  |                       onChange={(e) => handleArrayChange('benefits', index, e.target.value)} | ||||||
|  |                       placeholder="e.g., Health insurance, 401k, Flexible hours" | ||||||
|  |                     /> | ||||||
|  |                     {formData.benefits.length > 1 && ( | ||||||
|  |                       <button | ||||||
|  |                         type="button" | ||||||
|  |                         onClick={() => removeArrayItem('benefits', index)} | ||||||
|  |                         className="ml-2 p-2 text-red-600 hover:text-red-800" | ||||||
|  |                       > | ||||||
|  |                         <X className="h-4 w-4" /> | ||||||
|  |                       </button> | ||||||
|  |                     )} | ||||||
|  |                   </div> | ||||||
|  |                 ))} | ||||||
|  |                 <button | ||||||
|  |                   type="button" | ||||||
|  |                   onClick={() => addArrayItem('benefits')} | ||||||
|  |                   className="btn btn-secondary text-sm" | ||||||
|  |                 > | ||||||
|  |                   <Plus className="h-4 w-4 mr-1" /> | ||||||
|  |                   Add Benefit | ||||||
|  |                 </button> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         {/* Submit Button */} | ||||||
|  |         <div className="flex justify-end space-x-3"> | ||||||
|  |           <button | ||||||
|  |             type="button" | ||||||
|  |             onClick={() => navigate('/jobs')} | ||||||
|  |             className="btn btn-secondary" | ||||||
|  |           > | ||||||
|  |             Cancel | ||||||
|  |           </button> | ||||||
|  |           <button | ||||||
|  |             type="submit" | ||||||
|  |             disabled={createJobMutation.isLoading} | ||||||
|  |             className="btn btn-primary disabled:opacity-50" | ||||||
|  |           > | ||||||
|  |             <Save className="h-4 w-4 mr-2" /> | ||||||
|  |             {createJobMutation.isLoading ? 'Creating...' : 'Post Job'} | ||||||
|  |           </button> | ||||||
|  |         </div> | ||||||
|  |       </form> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default CreateJob; | ||||||
							
								
								
									
										284
									
								
								frontend/src/pages/Dashboard.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										284
									
								
								frontend/src/pages/Dashboard.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,284 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import { Link } from 'react-router-dom'; | ||||||
|  | import { useAuth } from '../contexts/AuthContext'; | ||||||
|  | import { useQuery } from 'react-query'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import { | ||||||
|  |   Briefcase, | ||||||
|  |   Users, | ||||||
|  |   FileText, | ||||||
|  |   Building, | ||||||
|  |   TrendingUp, | ||||||
|  |   Clock, | ||||||
|  |   CheckCircle, | ||||||
|  |   AlertCircle | ||||||
|  | } from 'lucide-react'; | ||||||
|  |  | ||||||
|  | const Dashboard = () => { | ||||||
|  |   const { user } = useAuth(); | ||||||
|  |  | ||||||
|  |   const { data: stats, isLoading } = useQuery('dashboard-stats', async () => { | ||||||
|  |     const [jobsRes, applicationsRes, candidatesRes, employersRes] = await Promise.all([ | ||||||
|  |       axios.get('/api/jobs?limit=1'), | ||||||
|  |       axios.get('/api/applications?limit=1'), | ||||||
|  |       user?.role === 'candidate' ? Promise.resolve({ data: { applications: { pagination: { total: 0 } } } }) : axios.get('/api/applications?limit=1'), | ||||||
|  |       user?.role === 'employer' || user?.role === 'admin' || user?.role === 'recruiter' ? axios.get('/api/employers?limit=1') : Promise.resolve({ data: [] }) | ||||||
|  |     ]); | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |       totalJobs: jobsRes.data.pagination?.total || 0, | ||||||
|  |       totalApplications: applicationsRes.data.pagination?.total || 0, | ||||||
|  |       totalCandidates: candidatesRes.data.pagination?.total || 0, | ||||||
|  |       totalEmployers: employersRes.data.length || 0 | ||||||
|  |     }; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const { data: recentJobs } = useQuery('recent-jobs', async () => { | ||||||
|  |     const response = await axios.get('/api/jobs?limit=5'); | ||||||
|  |     return response.data.jobs; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const { data: recentApplications } = useQuery('recent-applications', async () => { | ||||||
|  |     if (user?.role === 'candidate') { | ||||||
|  |       const response = await axios.get('/api/applications?limit=5'); | ||||||
|  |       return response.data.applications; | ||||||
|  |     } | ||||||
|  |     return []; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const statsCards = [ | ||||||
|  |     { | ||||||
|  |       name: 'Total Jobs', | ||||||
|  |       value: stats?.totalJobs || 0, | ||||||
|  |       icon: Briefcase, | ||||||
|  |       color: 'bg-blue-500' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       name: 'Applications', | ||||||
|  |       value: stats?.totalApplications || 0, | ||||||
|  |       icon: FileText, | ||||||
|  |       color: 'bg-green-500' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       name: 'Candidates', | ||||||
|  |       value: stats?.totalCandidates || 0, | ||||||
|  |       icon: Users, | ||||||
|  |       color: 'bg-purple-500' | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       name: 'Employers', | ||||||
|  |       value: stats?.totalEmployers || 0, | ||||||
|  |       icon: Building, | ||||||
|  |       color: 'bg-orange-500' | ||||||
|  |     } | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   const getGreeting = () => { | ||||||
|  |     const hour = new Date().getHours(); | ||||||
|  |     if (hour < 12) return 'Good morning'; | ||||||
|  |     if (hour < 18) return 'Good afternoon'; | ||||||
|  |     return 'Good evening'; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   if (isLoading) { | ||||||
|  |     return ( | ||||||
|  |       <div className="flex items-center justify-center h-64"> | ||||||
|  |         <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="space-y-6"> | ||||||
|  |       <div> | ||||||
|  |         <h1 className="text-2xl font-bold text-gray-900"> | ||||||
|  |           {getGreeting()}, {user?.firstName}! | ||||||
|  |         </h1> | ||||||
|  |         <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |           Welcome to your MysteryApp-Cursor dashboard | ||||||
|  |         </p> | ||||||
|  |         <div className="text-xs text-gray-500 mt-2"> | ||||||
|  |           Debug: User role = {user?.role || 'undefined'}, User ID = {user?.id || 'undefined'} | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {/* Stats Cards */} | ||||||
|  |       <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4"> | ||||||
|  |         {statsCards.map((card) => ( | ||||||
|  |           <div key={card.name} className="bg-white overflow-hidden shadow rounded-lg"> | ||||||
|  |             <div className="p-5"> | ||||||
|  |               <div className="flex items-center"> | ||||||
|  |                 <div className="flex-shrink-0"> | ||||||
|  |                   <div className={`p-3 rounded-md ${card.color}`}> | ||||||
|  |                     <card.icon className="h-6 w-6 text-white" /> | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |                 <div className="ml-5 w-0 flex-1"> | ||||||
|  |                   <dl> | ||||||
|  |                     <dt className="text-sm font-medium text-gray-500 truncate"> | ||||||
|  |                       {card.name} | ||||||
|  |                     </dt> | ||||||
|  |                     <dd className="text-lg font-medium text-gray-900"> | ||||||
|  |                       {card.value} | ||||||
|  |                     </dd> | ||||||
|  |                   </dl> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         ))} | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> | ||||||
|  |         {/* Recent Jobs */} | ||||||
|  |         <div className="bg-white shadow rounded-lg"> | ||||||
|  |           <div className="px-4 py-5 sm:p-6"> | ||||||
|  |             <h3 className="text-lg leading-6 font-medium text-gray-900"> | ||||||
|  |               Recent Job Postings | ||||||
|  |             </h3> | ||||||
|  |             <div className="mt-5"> | ||||||
|  |               {recentJobs?.length > 0 ? ( | ||||||
|  |                 <div className="space-y-3"> | ||||||
|  |                   {recentJobs.map((job) => ( | ||||||
|  |                     <div key={job.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"> | ||||||
|  |                       <div className="flex-1 min-w-0"> | ||||||
|  |                         <p className="text-sm font-medium text-gray-900 truncate"> | ||||||
|  |                           {job.title} | ||||||
|  |                         </p> | ||||||
|  |                         <p className="text-sm text-gray-500"> | ||||||
|  |                           {job.company_name} | ||||||
|  |                         </p> | ||||||
|  |                       </div> | ||||||
|  |                       <div className="flex items-center text-sm text-gray-500"> | ||||||
|  |                         <Clock className="h-4 w-4 mr-1" /> | ||||||
|  |                         {new Date(job.created_at).toLocaleDateString()} | ||||||
|  |                       </div> | ||||||
|  |                     </div> | ||||||
|  |                   ))} | ||||||
|  |                 </div> | ||||||
|  |               ) : ( | ||||||
|  |                 <p className="text-sm text-gray-500">No recent jobs found</p> | ||||||
|  |               )} | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         {/* Recent Applications */} | ||||||
|  |         <div className="bg-white shadow rounded-lg"> | ||||||
|  |           <div className="px-4 py-5 sm:p-6"> | ||||||
|  |             <h3 className="text-lg leading-6 font-medium text-gray-900"> | ||||||
|  |               Recent Applications | ||||||
|  |             </h3> | ||||||
|  |             <div className="mt-5"> | ||||||
|  |               {recentApplications?.length > 0 ? ( | ||||||
|  |                 <div className="space-y-3"> | ||||||
|  |                   {recentApplications.map((application) => ( | ||||||
|  |                     <div key={application.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"> | ||||||
|  |                       <div className="flex-1 min-w-0"> | ||||||
|  |                         <p className="text-sm font-medium text-gray-900 truncate"> | ||||||
|  |                           {application.job_title} | ||||||
|  |                         </p> | ||||||
|  |                         <p className="text-sm text-gray-500"> | ||||||
|  |                           {application.company_name} | ||||||
|  |                         </p> | ||||||
|  |                       </div> | ||||||
|  |                       <div className="flex items-center"> | ||||||
|  |                         <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ | ||||||
|  |                           application.status === 'applied' ? 'bg-blue-100 text-blue-800' : | ||||||
|  |                           application.status === 'reviewed' ? 'bg-yellow-100 text-yellow-800' : | ||||||
|  |                           application.status === 'shortlisted' ? 'bg-green-100 text-green-800' : | ||||||
|  |                           application.status === 'rejected' ? 'bg-red-100 text-red-800' : | ||||||
|  |                           'bg-gray-100 text-gray-800' | ||||||
|  |                         }`}> | ||||||
|  |                           {application.status} | ||||||
|  |                         </span> | ||||||
|  |                       </div> | ||||||
|  |                     </div> | ||||||
|  |                   ))} | ||||||
|  |                 </div> | ||||||
|  |               ) : ( | ||||||
|  |                 <p className="text-sm text-gray-500">No recent applications found</p> | ||||||
|  |               )} | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {/* Quick Actions */} | ||||||
|  |       <div className="bg-white shadow rounded-lg"> | ||||||
|  |         <div className="px-4 py-5 sm:p-6"> | ||||||
|  |           <h3 className="text-lg leading-6 font-medium text-gray-900"> | ||||||
|  |             Quick Actions | ||||||
|  |           </h3> | ||||||
|  |           <div className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> | ||||||
|  |             {user?.role === 'employer' && ( | ||||||
|  |               <Link | ||||||
|  |                 to="/jobs/create" | ||||||
|  |                 className="relative group bg-white p-6 focus-within:ring-2 focus-within:ring-inset focus-within:ring-primary-500 rounded-lg border border-gray-200 hover:border-gray-300" | ||||||
|  |               > | ||||||
|  |                 <div> | ||||||
|  |                   <span className="rounded-lg inline-flex p-3 bg-primary-50 text-primary-700 ring-4 ring-white"> | ||||||
|  |                     <Briefcase className="h-6 w-6" /> | ||||||
|  |                   </span> | ||||||
|  |                 </div> | ||||||
|  |                 <div className="mt-8"> | ||||||
|  |                   <h3 className="text-lg font-medium"> | ||||||
|  |                     <span className="absolute inset-0" /> | ||||||
|  |                     Post a Job | ||||||
|  |                   </h3> | ||||||
|  |                   <p className="mt-2 text-sm text-gray-500"> | ||||||
|  |                     Create a new job posting to attract candidates | ||||||
|  |                   </p> | ||||||
|  |                 </div> | ||||||
|  |               </Link> | ||||||
|  |             )} | ||||||
|  |              | ||||||
|  |             {user?.role === 'candidate' && ( | ||||||
|  |               <a | ||||||
|  |                 href="/jobs" | ||||||
|  |                 className="relative group bg-white p-6 focus-within:ring-2 focus-within:ring-inset focus-within:ring-primary-500 rounded-lg border border-gray-200 hover:border-gray-300" | ||||||
|  |               > | ||||||
|  |                 <div> | ||||||
|  |                   <span className="rounded-lg inline-flex p-3 bg-primary-50 text-primary-700 ring-4 ring-white"> | ||||||
|  |                     <TrendingUp className="h-6 w-6" /> | ||||||
|  |                   </span> | ||||||
|  |                 </div> | ||||||
|  |                 <div className="mt-8"> | ||||||
|  |                   <h3 className="text-lg font-medium"> | ||||||
|  |                     <span className="absolute inset-0" /> | ||||||
|  |                     Browse Jobs | ||||||
|  |                   </h3> | ||||||
|  |                   <p className="mt-2 text-sm text-gray-500"> | ||||||
|  |                     Find your next career opportunity | ||||||
|  |                   </p> | ||||||
|  |                 </div> | ||||||
|  |               </a> | ||||||
|  |             )} | ||||||
|  |  | ||||||
|  |             <a | ||||||
|  |               href="/applications" | ||||||
|  |               className="relative group bg-white p-6 focus-within:ring-2 focus-within:ring-inset focus-within:ring-primary-500 rounded-lg border border-gray-200 hover:border-gray-300" | ||||||
|  |             > | ||||||
|  |               <div> | ||||||
|  |                 <span className="rounded-lg inline-flex p-3 bg-primary-50 text-primary-700 ring-4 ring-white"> | ||||||
|  |                   <FileText className="h-6 w-6" /> | ||||||
|  |                 </span> | ||||||
|  |               </div> | ||||||
|  |               <div className="mt-8"> | ||||||
|  |                 <h3 className="text-lg font-medium"> | ||||||
|  |                   <span className="absolute inset-0" /> | ||||||
|  |                   View Applications | ||||||
|  |                 </h3> | ||||||
|  |                 <p className="mt-2 text-sm text-gray-500"> | ||||||
|  |                   Track your application status | ||||||
|  |                 </p> | ||||||
|  |               </div> | ||||||
|  |             </a> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Dashboard; | ||||||
							
								
								
									
										111
									
								
								frontend/src/pages/EmployerDetails.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								frontend/src/pages/EmployerDetails.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,111 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import { useParams } from 'react-router-dom'; | ||||||
|  | import { useQuery } from 'react-query'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import { Building, MapPin, Users, Globe, Mail, Phone } from 'lucide-react'; | ||||||
|  |  | ||||||
|  | const EmployerDetails = () => { | ||||||
|  |   const { id } = useParams(); | ||||||
|  |  | ||||||
|  |   const { data: employer, isLoading } = useQuery(['employer', id], async () => { | ||||||
|  |     const response = await axios.get(`/api/employers/${id}`); | ||||||
|  |     return response.data; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   if (isLoading) { | ||||||
|  |     return ( | ||||||
|  |       <div className="flex items-center justify-center h-64"> | ||||||
|  |         <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (!employer) { | ||||||
|  |     return ( | ||||||
|  |       <div className="text-center py-12"> | ||||||
|  |         <h3 className="text-lg font-medium text-gray-900">Employer not found</h3> | ||||||
|  |         <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |           The employer you're looking for doesn't exist. | ||||||
|  |         </p> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="space-y-6"> | ||||||
|  |       <div className="bg-white shadow rounded-lg"> | ||||||
|  |         <div className="px-4 py-5 sm:p-6"> | ||||||
|  |           <div className="flex items-start"> | ||||||
|  |             <div className="flex-shrink-0"> | ||||||
|  |               <div className="h-16 w-16 rounded-full bg-primary-100 flex items-center justify-center"> | ||||||
|  |                 <Building className="h-8 w-8 text-primary-600" /> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |             <div className="ml-6 flex-1"> | ||||||
|  |               <h1 className="text-2xl font-bold text-gray-900"> | ||||||
|  |                 {employer.company_name} | ||||||
|  |               </h1> | ||||||
|  |               <p className="text-lg text-gray-600">{employer.first_name} {employer.last_name}</p> | ||||||
|  |               <p className="text-sm text-gray-500">{employer.email}</p> | ||||||
|  |                | ||||||
|  |               <div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2"> | ||||||
|  |                 {employer.industry && ( | ||||||
|  |                   <div className="flex items-center text-sm text-gray-500"> | ||||||
|  |                     <Building className="h-4 w-4 mr-2" /> | ||||||
|  |                     {employer.industry} | ||||||
|  |                   </div> | ||||||
|  |                 )} | ||||||
|  |                  | ||||||
|  |                 {employer.company_size && ( | ||||||
|  |                   <div className="flex items-center text-sm text-gray-500"> | ||||||
|  |                     <Users className="h-4 w-4 mr-2" /> | ||||||
|  |                     {employer.company_size} employees | ||||||
|  |                   </div> | ||||||
|  |                 )} | ||||||
|  |                  | ||||||
|  |                 {employer.website && ( | ||||||
|  |                   <div className="flex items-center text-sm text-gray-500"> | ||||||
|  |                     <Globe className="h-4 w-4 mr-2" /> | ||||||
|  |                     <a href={employer.website} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500"> | ||||||
|  |                       Website | ||||||
|  |                     </a> | ||||||
|  |                   </div> | ||||||
|  |                 )} | ||||||
|  |                  | ||||||
|  |                 {employer.phone && ( | ||||||
|  |                   <div className="flex items-center text-sm text-gray-500"> | ||||||
|  |                     <Phone className="h-4 w-4 mr-2" /> | ||||||
|  |                     {employer.phone} | ||||||
|  |                   </div> | ||||||
|  |                 )} | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {employer.description && ( | ||||||
|  |         <div className="bg-white shadow rounded-lg"> | ||||||
|  |           <div className="px-4 py-5 sm:p-6"> | ||||||
|  |             <h2 className="text-lg font-medium text-gray-900 mb-4">About {employer.company_name}</h2> | ||||||
|  |             <p className="text-gray-600 whitespace-pre-wrap">{employer.description}</p> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  |  | ||||||
|  |       {employer.address && ( | ||||||
|  |         <div className="bg-white shadow rounded-lg"> | ||||||
|  |           <div className="px-4 py-5 sm:p-6"> | ||||||
|  |             <h2 className="text-lg font-medium text-gray-900 mb-4">Address</h2> | ||||||
|  |             <div className="flex items-start"> | ||||||
|  |               <MapPin className="h-5 w-5 text-gray-400 mr-3 mt-0.5" /> | ||||||
|  |               <p className="text-gray-600">{employer.address}</p> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default EmployerDetails; | ||||||
							
								
								
									
										98
									
								
								frontend/src/pages/Employers.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								frontend/src/pages/Employers.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | |||||||
|  | import React from 'react'; | ||||||
|  | import { Link } from 'react-router-dom'; | ||||||
|  | import { useQuery } from 'react-query'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import { Building, MapPin, Users, Globe } from 'lucide-react'; | ||||||
|  |  | ||||||
|  | const Employers = () => { | ||||||
|  |   const { data, isLoading } = useQuery('employers', async () => { | ||||||
|  |     const response = await axios.get('/api/employers'); | ||||||
|  |     return response.data; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   if (isLoading) { | ||||||
|  |     return ( | ||||||
|  |       <div className="flex items-center justify-center h-64"> | ||||||
|  |         <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="space-y-6"> | ||||||
|  |       <div> | ||||||
|  |         <h1 className="text-2xl font-bold text-gray-900">Employers</h1> | ||||||
|  |         <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |           Browse companies and employers | ||||||
|  |         </p> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div className="grid grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3"> | ||||||
|  |         {data?.length > 0 ? ( | ||||||
|  |           data.map((employer) => ( | ||||||
|  |             <div key={employer.id} className="bg-white shadow rounded-lg"> | ||||||
|  |               <div className="px-4 py-5 sm:p-6"> | ||||||
|  |                 <div className="flex items-start"> | ||||||
|  |                   <div className="flex-shrink-0"> | ||||||
|  |                     <div className="h-12 w-12 rounded-full bg-primary-100 flex items-center justify-center"> | ||||||
|  |                       <Building className="h-6 w-6 text-primary-600" /> | ||||||
|  |                     </div> | ||||||
|  |                   </div> | ||||||
|  |                   <div className="ml-4 flex-1"> | ||||||
|  |                     <h3 className="text-lg font-medium text-gray-900"> | ||||||
|  |                       <Link to={`/employers/${employer.id}`} className="hover:text-primary-600"> | ||||||
|  |                         {employer.company_name} | ||||||
|  |                       </Link> | ||||||
|  |                     </h3> | ||||||
|  |                     <p className="text-sm text-gray-500">{employer.first_name} {employer.last_name}</p> | ||||||
|  |                      | ||||||
|  |                     {employer.industry && ( | ||||||
|  |                       <div className="mt-2 flex items-center text-sm text-gray-500"> | ||||||
|  |                         <Building className="h-4 w-4 mr-1" /> | ||||||
|  |                         {employer.industry} | ||||||
|  |                       </div> | ||||||
|  |                     )} | ||||||
|  |                      | ||||||
|  |                     {employer.company_size && ( | ||||||
|  |                       <div className="mt-1 flex items-center text-sm text-gray-500"> | ||||||
|  |                         <Users className="h-4 w-4 mr-1" /> | ||||||
|  |                         {employer.company_size} employees | ||||||
|  |                       </div> | ||||||
|  |                     )} | ||||||
|  |                      | ||||||
|  |                     {employer.website && ( | ||||||
|  |                       <div className="mt-1 flex items-center text-sm text-gray-500"> | ||||||
|  |                         <Globe className="h-4 w-4 mr-1" /> | ||||||
|  |                         <a href={employer.website} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500"> | ||||||
|  |                           Website | ||||||
|  |                         </a> | ||||||
|  |                       </div> | ||||||
|  |                     )} | ||||||
|  |                      | ||||||
|  |                     {employer.description && ( | ||||||
|  |                       <div className="mt-3"> | ||||||
|  |                         <p className="text-sm text-gray-600 line-clamp-3"> | ||||||
|  |                           {employer.description} | ||||||
|  |                         </p> | ||||||
|  |                       </div> | ||||||
|  |                     )} | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           )) | ||||||
|  |         ) : ( | ||||||
|  |           <div className="col-span-full text-center py-12"> | ||||||
|  |             <Building className="mx-auto h-12 w-12 text-gray-400" /> | ||||||
|  |             <h3 className="mt-2 text-sm font-medium text-gray-900">No employers found</h3> | ||||||
|  |             <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |               No employers have registered yet. | ||||||
|  |             </p> | ||||||
|  |           </div> | ||||||
|  |         )} | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Employers; | ||||||
							
								
								
									
										293
									
								
								frontend/src/pages/JobDetails.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										293
									
								
								frontend/src/pages/JobDetails.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,293 @@ | |||||||
|  | import React, { useState } from 'react'; | ||||||
|  | import { useParams, Link } from 'react-router-dom'; | ||||||
|  | import { useQuery } from 'react-query'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import { useAuth } from '../contexts/AuthContext'; | ||||||
|  | import { MapPin, Clock, DollarSign, Briefcase, Users, Calendar } from 'lucide-react'; | ||||||
|  | import toast from 'react-hot-toast'; | ||||||
|  |  | ||||||
|  | const JobDetails = () => { | ||||||
|  |   const { id } = useParams(); | ||||||
|  |   const { user } = useAuth(); | ||||||
|  |   const [applying, setApplying] = useState(false); | ||||||
|  |   const [coverLetter, setCoverLetter] = useState(''); | ||||||
|  |  | ||||||
|  |   const { data: job, isLoading } = useQuery(['job', id], async () => { | ||||||
|  |     const response = await axios.get(`/api/jobs/${id}`); | ||||||
|  |     return response.data; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const handleApply = async () => { | ||||||
|  |     if (!coverLetter.trim()) { | ||||||
|  |       toast.error('Please provide a cover letter'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     setApplying(true); | ||||||
|  |     try { | ||||||
|  |       await axios.post('/api/applications', { | ||||||
|  |         jobId: id, | ||||||
|  |         coverLetter: coverLetter.trim() | ||||||
|  |       }); | ||||||
|  |       toast.success('Application submitted successfully!'); | ||||||
|  |       setCoverLetter(''); | ||||||
|  |     } catch (error) { | ||||||
|  |       toast.error(error.response?.data?.error || 'Failed to submit application'); | ||||||
|  |     } finally { | ||||||
|  |       setApplying(false); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   if (isLoading) { | ||||||
|  |     return ( | ||||||
|  |       <div className="flex items-center justify-center h-64"> | ||||||
|  |         <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (!job) { | ||||||
|  |     return ( | ||||||
|  |       <div className="text-center py-12"> | ||||||
|  |         <h3 className="text-lg font-medium text-gray-900">Job not found</h3> | ||||||
|  |         <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |           The job you're looking for doesn't exist or has been removed. | ||||||
|  |         </p> | ||||||
|  |         <div className="mt-6"> | ||||||
|  |           <Link to="/jobs" className="btn btn-primary"> | ||||||
|  |             Browse Jobs | ||||||
|  |           </Link> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const formatSalary = (min, max, currency = 'USD') => { | ||||||
|  |     if (!min && !max) return 'Salary not specified'; | ||||||
|  |     if (!min) return `Up to ${currency} ${max?.toLocaleString()}`; | ||||||
|  |     if (!max) return `From ${currency} ${min?.toLocaleString()}`; | ||||||
|  |     return `${currency} ${min?.toLocaleString()} - ${max?.toLocaleString()}`; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="space-y-6"> | ||||||
|  |       <div className="flex items-center justify-between"> | ||||||
|  |         <div> | ||||||
|  |           <Link to="/jobs" className="text-sm text-primary-600 hover:text-primary-500"> | ||||||
|  |             ← Back to Jobs | ||||||
|  |           </Link> | ||||||
|  |           <h1 className="mt-2 text-3xl font-bold text-gray-900">{job.title}</h1> | ||||||
|  |           <div className="mt-2 flex items-center text-lg text-gray-600"> | ||||||
|  |             <Briefcase className="h-5 w-5 mr-2" /> | ||||||
|  |             {job.company_name} | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |         {user?.role === 'candidate' && job.status === 'active' && ( | ||||||
|  |           <div className="flex space-x-3"> | ||||||
|  |             <button | ||||||
|  |               onClick={handleApply} | ||||||
|  |               disabled={applying} | ||||||
|  |               className="btn btn-primary disabled:opacity-50" | ||||||
|  |             > | ||||||
|  |               {applying ? 'Applying...' : 'Apply Now'} | ||||||
|  |             </button> | ||||||
|  |           </div> | ||||||
|  |         )} | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> | ||||||
|  |         <div className="lg:col-span-2 space-y-6"> | ||||||
|  |           {/* Job Description */} | ||||||
|  |           <div className="bg-white shadow rounded-lg"> | ||||||
|  |             <div className="px-4 py-5 sm:p-6"> | ||||||
|  |               <h2 className="text-lg font-medium text-gray-900 mb-4">Job Description</h2> | ||||||
|  |               <div className="prose max-w-none"> | ||||||
|  |                 <p className="text-gray-600 whitespace-pre-wrap">{job.description}</p> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           {/* Requirements */} | ||||||
|  |           {job.requirements && job.requirements.length > 0 && ( | ||||||
|  |             <div className="bg-white shadow rounded-lg"> | ||||||
|  |               <div className="px-4 py-5 sm:p-6"> | ||||||
|  |                 <h2 className="text-lg font-medium text-gray-900 mb-4">Requirements</h2> | ||||||
|  |                 <ul className="list-disc list-inside space-y-2"> | ||||||
|  |                   {job.requirements.map((requirement, index) => ( | ||||||
|  |                     <li key={index} className="text-gray-600">{requirement}</li> | ||||||
|  |                   ))} | ||||||
|  |                 </ul> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           )} | ||||||
|  |  | ||||||
|  |           {/* Responsibilities */} | ||||||
|  |           {job.responsibilities && job.responsibilities.length > 0 && ( | ||||||
|  |             <div className="bg-white shadow rounded-lg"> | ||||||
|  |               <div className="px-4 py-5 sm:p-6"> | ||||||
|  |                 <h2 className="text-lg font-medium text-gray-900 mb-4">Responsibilities</h2> | ||||||
|  |                 <ul className="list-disc list-inside space-y-2"> | ||||||
|  |                   {job.responsibilities.map((responsibility, index) => ( | ||||||
|  |                     <li key={index} className="text-gray-600">{responsibility}</li> | ||||||
|  |                   ))} | ||||||
|  |                 </ul> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           )} | ||||||
|  |  | ||||||
|  |           {/* Skills Required */} | ||||||
|  |           {job.skills_required && job.skills_required.length > 0 && ( | ||||||
|  |             <div className="bg-white shadow rounded-lg"> | ||||||
|  |               <div className="px-4 py-5 sm:p-6"> | ||||||
|  |                 <h2 className="text-lg font-medium text-gray-900 mb-4">Required Skills</h2> | ||||||
|  |                 <div className="flex flex-wrap gap-2"> | ||||||
|  |                   {job.skills_required.map((skill, index) => ( | ||||||
|  |                     <span | ||||||
|  |                       key={index} | ||||||
|  |                       className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800" | ||||||
|  |                     > | ||||||
|  |                       {skill} | ||||||
|  |                     </span> | ||||||
|  |                   ))} | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           )} | ||||||
|  |  | ||||||
|  |           {/* Benefits */} | ||||||
|  |           {job.benefits && job.benefits.length > 0 && ( | ||||||
|  |             <div className="bg-white shadow rounded-lg"> | ||||||
|  |               <div className="px-4 py-5 sm:p-6"> | ||||||
|  |                 <h2 className="text-lg font-medium text-gray-900 mb-4">Benefits</h2> | ||||||
|  |                 <ul className="list-disc list-inside space-y-2"> | ||||||
|  |                   {job.benefits.map((benefit, index) => ( | ||||||
|  |                     <li key={index} className="text-gray-600">{benefit}</li> | ||||||
|  |                   ))} | ||||||
|  |                 </ul> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           )} | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         {/* Sidebar */} | ||||||
|  |         <div className="space-y-6"> | ||||||
|  |           {/* Job Details */} | ||||||
|  |           <div className="bg-white shadow rounded-lg"> | ||||||
|  |             <div className="px-4 py-5 sm:p-6"> | ||||||
|  |               <h3 className="text-lg font-medium text-gray-900 mb-4">Job Details</h3> | ||||||
|  |               <div className="space-y-4"> | ||||||
|  |                 <div className="flex items-center"> | ||||||
|  |                   <MapPin className="h-5 w-5 text-gray-400 mr-3" /> | ||||||
|  |                   <div> | ||||||
|  |                     <p className="text-sm font-medium text-gray-900">{job.location}</p> | ||||||
|  |                     {job.remote_allowed && ( | ||||||
|  |                       <p className="text-sm text-gray-500">Remote work allowed</p> | ||||||
|  |                     )} | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |  | ||||||
|  |                 <div className="flex items-center"> | ||||||
|  |                   <Briefcase className="h-5 w-5 text-gray-400 mr-3" /> | ||||||
|  |                   <div> | ||||||
|  |                     <p className="text-sm font-medium text-gray-900 capitalize"> | ||||||
|  |                       {job.employment_type?.replace('-', ' ')} | ||||||
|  |                     </p> | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |  | ||||||
|  |                 <div className="flex items-center"> | ||||||
|  |                   <DollarSign className="h-5 w-5 text-gray-400 mr-3" /> | ||||||
|  |                   <div> | ||||||
|  |                     <p className="text-sm font-medium text-gray-900"> | ||||||
|  |                       {formatSalary(job.salary_min, job.salary_max, job.currency)} | ||||||
|  |                     </p> | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |  | ||||||
|  |                 {job.experience_level && ( | ||||||
|  |                   <div className="flex items-center"> | ||||||
|  |                     <Users className="h-5 w-5 text-gray-400 mr-3" /> | ||||||
|  |                     <div> | ||||||
|  |                       <p className="text-sm font-medium text-gray-900 capitalize"> | ||||||
|  |                         {job.experience_level} level | ||||||
|  |                       </p> | ||||||
|  |                     </div> | ||||||
|  |                   </div> | ||||||
|  |                 )} | ||||||
|  |  | ||||||
|  |                 <div className="flex items-center"> | ||||||
|  |                   <Clock className="h-5 w-5 text-gray-400 mr-3" /> | ||||||
|  |                   <div> | ||||||
|  |                     <p className="text-sm font-medium text-gray-900"> | ||||||
|  |                       Posted {new Date(job.created_at).toLocaleDateString()} | ||||||
|  |                     </p> | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |  | ||||||
|  |                 {job.application_deadline && ( | ||||||
|  |                   <div className="flex items-center"> | ||||||
|  |                     <Calendar className="h-5 w-5 text-gray-400 mr-3" /> | ||||||
|  |                     <div> | ||||||
|  |                       <p className="text-sm font-medium text-gray-900"> | ||||||
|  |                         Apply by {new Date(job.application_deadline).toLocaleDateString()} | ||||||
|  |                       </p> | ||||||
|  |                     </div> | ||||||
|  |                   </div> | ||||||
|  |                 )} | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           {/* Company Info */} | ||||||
|  |           <div className="bg-white shadow rounded-lg"> | ||||||
|  |             <div className="px-4 py-5 sm:p-6"> | ||||||
|  |               <h3 className="text-lg font-medium text-gray-900 mb-4">Company</h3> | ||||||
|  |               <div className="space-y-2"> | ||||||
|  |                 <p className="text-sm font-medium text-gray-900">{job.company_name}</p> | ||||||
|  |                 {job.industry && ( | ||||||
|  |                   <p className="text-sm text-gray-500">{job.industry}</p> | ||||||
|  |                 )} | ||||||
|  |                 {job.company_size && ( | ||||||
|  |                   <p className="text-sm text-gray-500">{job.company_size} employees</p> | ||||||
|  |                 )} | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           {/* Apply Section for Candidates */} | ||||||
|  |           {user?.role === 'candidate' && job.status === 'active' && ( | ||||||
|  |             <div className="bg-white shadow rounded-lg"> | ||||||
|  |               <div className="px-4 py-5 sm:p-6"> | ||||||
|  |                 <h3 className="text-lg font-medium text-gray-900 mb-4">Apply for this job</h3> | ||||||
|  |                 <div className="space-y-4"> | ||||||
|  |                   <div> | ||||||
|  |                     <label htmlFor="coverLetter" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                       Cover Letter | ||||||
|  |                     </label> | ||||||
|  |                     <textarea | ||||||
|  |                       id="coverLetter" | ||||||
|  |                       rows={4} | ||||||
|  |                       className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm" | ||||||
|  |                       placeholder="Tell us why you're interested in this position..." | ||||||
|  |                       value={coverLetter} | ||||||
|  |                       onChange={(e) => setCoverLetter(e.target.value)} | ||||||
|  |                     /> | ||||||
|  |                   </div> | ||||||
|  |                   <button | ||||||
|  |                     onClick={handleApply} | ||||||
|  |                     disabled={applying || !coverLetter.trim()} | ||||||
|  |                     className="w-full btn btn-primary disabled:opacity-50" | ||||||
|  |                   > | ||||||
|  |                     {applying ? 'Applying...' : 'Submit Application'} | ||||||
|  |                   </button> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           )} | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default JobDetails; | ||||||
							
								
								
									
										295
									
								
								frontend/src/pages/Jobs.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										295
									
								
								frontend/src/pages/Jobs.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,295 @@ | |||||||
|  | import React, { useState } from 'react'; | ||||||
|  | import { Link } from 'react-router-dom'; | ||||||
|  | import { useQuery } from 'react-query'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import { useAuth } from '../contexts/AuthContext'; | ||||||
|  | import { Search, MapPin, Clock, DollarSign, Briefcase, Plus } from 'lucide-react'; | ||||||
|  |  | ||||||
|  | const Jobs = () => { | ||||||
|  |   const { user } = useAuth(); | ||||||
|  |   const [filters, setFilters] = useState({ | ||||||
|  |     search: '', | ||||||
|  |     location: '', | ||||||
|  |     employmentType: '', | ||||||
|  |     experienceLevel: '' | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const { data, isLoading, refetch } = useQuery(['jobs', filters], async () => { | ||||||
|  |     const params = new URLSearchParams(); | ||||||
|  |     Object.entries(filters).forEach(([key, value]) => { | ||||||
|  |       if (value) params.append(key, value); | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     const response = await axios.get(`/api/jobs?${params.toString()}`); | ||||||
|  |     return response.data; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const handleFilterChange = (e) => { | ||||||
|  |     setFilters({ | ||||||
|  |       ...filters, | ||||||
|  |       [e.target.name]: e.target.value | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleSearch = (e) => { | ||||||
|  |     e.preventDefault(); | ||||||
|  |     refetch(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const formatSalary = (min, max, currency = 'USD') => { | ||||||
|  |     if (!min && !max) return 'Salary not specified'; | ||||||
|  |     if (!min) return `Up to ${currency} ${max?.toLocaleString()}`; | ||||||
|  |     if (!max) return `From ${currency} ${min?.toLocaleString()}`; | ||||||
|  |     return `${currency} ${min?.toLocaleString()} - ${max?.toLocaleString()}`; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const getStatusColor = (status) => { | ||||||
|  |     switch (status) { | ||||||
|  |       case 'active': return 'bg-green-100 text-green-800'; | ||||||
|  |       case 'paused': return 'bg-yellow-100 text-yellow-800'; | ||||||
|  |       case 'closed': return 'bg-red-100 text-red-800'; | ||||||
|  |       default: return 'bg-gray-100 text-gray-800'; | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   if (isLoading) { | ||||||
|  |     return ( | ||||||
|  |       <div className="flex items-center justify-center h-64"> | ||||||
|  |         <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="space-y-6"> | ||||||
|  |       <div className="flex items-center justify-between"> | ||||||
|  |         <div> | ||||||
|  |           <h1 className="text-2xl font-bold text-gray-900">Job Postings</h1> | ||||||
|  |           <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |             Find your next career opportunity | ||||||
|  |           </p> | ||||||
|  |         </div> | ||||||
|  |         {/* Debug info */} | ||||||
|  |         <div className="text-xs text-gray-500 mb-2"> | ||||||
|  |           Debug: User role = {user?.role || 'undefined'} | ||||||
|  |         </div> | ||||||
|  |         {(user?.role === 'employer' || user?.role === 'recruiter') && ( | ||||||
|  |           <Link | ||||||
|  |             to="/jobs/create" | ||||||
|  |             className="btn btn-primary" | ||||||
|  |           > | ||||||
|  |             <Plus className="h-4 w-4 mr-2" /> | ||||||
|  |             Post a Job | ||||||
|  |           </Link> | ||||||
|  |         )} | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {/* Filters */} | ||||||
|  |       <div className="bg-white shadow rounded-lg"> | ||||||
|  |         <div className="px-4 py-5 sm:p-6"> | ||||||
|  |           <form onSubmit={handleSearch} className="space-y-4"> | ||||||
|  |             <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="search" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Search | ||||||
|  |                 </label> | ||||||
|  |                 <div className="mt-1 relative"> | ||||||
|  |                   <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||||||
|  |                     <Search className="h-5 w-5 text-gray-400" /> | ||||||
|  |                   </div> | ||||||
|  |                   <input | ||||||
|  |                     type="text" | ||||||
|  |                     name="search" | ||||||
|  |                     id="search" | ||||||
|  |                     className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm" | ||||||
|  |                     placeholder="Job title or keywords" | ||||||
|  |                     value={filters.search} | ||||||
|  |                     onChange={handleFilterChange} | ||||||
|  |                   /> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="location" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Location | ||||||
|  |                 </label> | ||||||
|  |                 <div className="mt-1 relative"> | ||||||
|  |                   <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||||||
|  |                     <MapPin className="h-5 w-5 text-gray-400" /> | ||||||
|  |                   </div> | ||||||
|  |                   <input | ||||||
|  |                     type="text" | ||||||
|  |                     name="location" | ||||||
|  |                     id="location" | ||||||
|  |                     className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm" | ||||||
|  |                     placeholder="City, state, or remote" | ||||||
|  |                     value={filters.location} | ||||||
|  |                     onChange={handleFilterChange} | ||||||
|  |                   /> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="employmentType" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Employment Type | ||||||
|  |                 </label> | ||||||
|  |                 <select | ||||||
|  |                   id="employmentType" | ||||||
|  |                   name="employmentType" | ||||||
|  |                   className="mt-1 block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md" | ||||||
|  |                   value={filters.employmentType} | ||||||
|  |                   onChange={handleFilterChange} | ||||||
|  |                 > | ||||||
|  |                   <option value="">All Types</option> | ||||||
|  |                   <option value="full-time">Full-time</option> | ||||||
|  |                   <option value="part-time">Part-time</option> | ||||||
|  |                   <option value="contract">Contract</option> | ||||||
|  |                   <option value="internship">Internship</option> | ||||||
|  |                 </select> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="experienceLevel" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Experience Level | ||||||
|  |                 </label> | ||||||
|  |                 <select | ||||||
|  |                   id="experienceLevel" | ||||||
|  |                   name="experienceLevel" | ||||||
|  |                   className="mt-1 block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md" | ||||||
|  |                   value={filters.experienceLevel} | ||||||
|  |                   onChange={handleFilterChange} | ||||||
|  |                 > | ||||||
|  |                   <option value="">All Levels</option> | ||||||
|  |                   <option value="entry">Entry Level</option> | ||||||
|  |                   <option value="mid">Mid Level</option> | ||||||
|  |                   <option value="senior">Senior Level</option> | ||||||
|  |                   <option value="lead">Lead</option> | ||||||
|  |                   <option value="executive">Executive</option> | ||||||
|  |                 </select> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div className="flex justify-end"> | ||||||
|  |               <button | ||||||
|  |                 type="submit" | ||||||
|  |                 className="btn btn-primary" | ||||||
|  |               > | ||||||
|  |                 Search Jobs | ||||||
|  |               </button> | ||||||
|  |             </div> | ||||||
|  |           </form> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {/* Jobs List */} | ||||||
|  |       <div className="space-y-4"> | ||||||
|  |         {data?.jobs?.length > 0 ? ( | ||||||
|  |           data.jobs.map((job) => ( | ||||||
|  |             <div key={job.id} className="bg-white shadow rounded-lg"> | ||||||
|  |               <div className="px-4 py-5 sm:p-6"> | ||||||
|  |                 <div className="flex items-start justify-between"> | ||||||
|  |                   <div className="flex-1"> | ||||||
|  |                     <div className="flex items-center"> | ||||||
|  |                       <h3 className="text-lg font-medium text-gray-900"> | ||||||
|  |                         <Link to={`/jobs/${job.id}`} className="hover:text-primary-600"> | ||||||
|  |                           {job.title} | ||||||
|  |                         </Link> | ||||||
|  |                       </h3> | ||||||
|  |                       <span className={`ml-3 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(job.status)}`}> | ||||||
|  |                         {job.status} | ||||||
|  |                       </span> | ||||||
|  |                     </div> | ||||||
|  |                     <div className="mt-1 flex items-center text-sm text-gray-500"> | ||||||
|  |                       <Briefcase className="h-4 w-4 mr-1" /> | ||||||
|  |                       {job.company_name} | ||||||
|  |                     </div> | ||||||
|  |                     <div className="mt-2 flex items-center text-sm text-gray-500 space-x-4"> | ||||||
|  |                       <div className="flex items-center"> | ||||||
|  |                         <MapPin className="h-4 w-4 mr-1" /> | ||||||
|  |                         {job.location} | ||||||
|  |                         {job.remote_allowed && <span className="ml-1">(Remote OK)</span>} | ||||||
|  |                       </div> | ||||||
|  |                       <div className="flex items-center"> | ||||||
|  |                         <DollarSign className="h-4 w-4 mr-1" /> | ||||||
|  |                         {formatSalary(job.salary_min, job.salary_max, job.currency)} | ||||||
|  |                       </div> | ||||||
|  |                       <div className="flex items-center"> | ||||||
|  |                         <Clock className="h-4 w-4 mr-1" /> | ||||||
|  |                         {new Date(job.created_at).toLocaleDateString()} | ||||||
|  |                       </div> | ||||||
|  |                     </div> | ||||||
|  |                     <div className="mt-3"> | ||||||
|  |                       <p className="text-sm text-gray-600 line-clamp-2"> | ||||||
|  |                         {job.description} | ||||||
|  |                       </p> | ||||||
|  |                     </div> | ||||||
|  |                     {job.skills_required && job.skills_required.length > 0 && ( | ||||||
|  |                       <div className="mt-3"> | ||||||
|  |                         <div className="flex flex-wrap gap-2"> | ||||||
|  |                           {job.skills_required.slice(0, 5).map((skill, index) => ( | ||||||
|  |                             <span | ||||||
|  |                               key={index} | ||||||
|  |                               className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800" | ||||||
|  |                             > | ||||||
|  |                               {skill} | ||||||
|  |                             </span> | ||||||
|  |                           ))} | ||||||
|  |                           {job.skills_required.length > 5 && ( | ||||||
|  |                             <span className="text-xs text-gray-500"> | ||||||
|  |                               +{job.skills_required.length - 5} more | ||||||
|  |                             </span> | ||||||
|  |                           )} | ||||||
|  |                         </div> | ||||||
|  |                       </div> | ||||||
|  |                     )} | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           )) | ||||||
|  |         ) : ( | ||||||
|  |           <div className="text-center py-12"> | ||||||
|  |             <Briefcase className="mx-auto h-12 w-12 text-gray-400" /> | ||||||
|  |             <h3 className="mt-2 text-sm font-medium text-gray-900">No jobs found</h3> | ||||||
|  |             <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |               Try adjusting your search criteria | ||||||
|  |             </p> | ||||||
|  |           </div> | ||||||
|  |         )} | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {/* Pagination */} | ||||||
|  |       {data?.pagination && data.pagination.pages > 1 && ( | ||||||
|  |         <div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6"> | ||||||
|  |           <div className="flex-1 flex justify-between sm:hidden"> | ||||||
|  |             <button className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"> | ||||||
|  |               Previous | ||||||
|  |             </button> | ||||||
|  |             <button className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"> | ||||||
|  |               Next | ||||||
|  |             </button> | ||||||
|  |           </div> | ||||||
|  |           <div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> | ||||||
|  |             <div> | ||||||
|  |               <p className="text-sm text-gray-700"> | ||||||
|  |                 Showing{' '} | ||||||
|  |                 <span className="font-medium"> | ||||||
|  |                   {((data.pagination.page - 1) * data.pagination.limit) + 1} | ||||||
|  |                 </span>{' '} | ||||||
|  |                 to{' '} | ||||||
|  |                 <span className="font-medium"> | ||||||
|  |                   {Math.min(data.pagination.page * data.pagination.limit, data.pagination.total)} | ||||||
|  |                 </span>{' '} | ||||||
|  |                 of{' '} | ||||||
|  |                 <span className="font-medium">{data.pagination.total}</span>{' '} | ||||||
|  |                 results | ||||||
|  |               </p> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Jobs; | ||||||
							
								
								
									
										136
									
								
								frontend/src/pages/Login.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								frontend/src/pages/Login.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,136 @@ | |||||||
|  | import React, { useState } from 'react'; | ||||||
|  | import { Link } from 'react-router-dom'; | ||||||
|  | import { useAuth } from '../contexts/AuthContext'; | ||||||
|  | import { Eye, EyeOff, Mail, Lock } from 'lucide-react'; | ||||||
|  |  | ||||||
|  | const Login = () => { | ||||||
|  |   const { login } = useAuth(); | ||||||
|  |   const [formData, setFormData] = useState({ | ||||||
|  |     email: '', | ||||||
|  |     password: '' | ||||||
|  |   }); | ||||||
|  |   const [showPassword, setShowPassword] = useState(false); | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |  | ||||||
|  |   const handleChange = (e) => { | ||||||
|  |     setFormData({ | ||||||
|  |       ...formData, | ||||||
|  |       [e.target.name]: e.target.value | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleSubmit = async (e) => { | ||||||
|  |     e.preventDefault(); | ||||||
|  |     setLoading(true); | ||||||
|  |      | ||||||
|  |     const result = await login(formData.email, formData.password); | ||||||
|  |      | ||||||
|  |     if (!result.success) { | ||||||
|  |       setLoading(false); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"> | ||||||
|  |       <div className="max-w-md w-full space-y-8"> | ||||||
|  |         <div> | ||||||
|  |           <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900"> | ||||||
|  |             Sign in to your account | ||||||
|  |           </h2> | ||||||
|  |           <p className="mt-2 text-center text-sm text-gray-600"> | ||||||
|  |             Or{' '} | ||||||
|  |             <Link | ||||||
|  |               to="/register" | ||||||
|  |               className="font-medium text-primary-600 hover:text-primary-500" | ||||||
|  |             > | ||||||
|  |               create a new account | ||||||
|  |             </Link> | ||||||
|  |           </p> | ||||||
|  |         </div> | ||||||
|  |         <form className="mt-8 space-y-6" onSubmit={handleSubmit}> | ||||||
|  |           <div className="rounded-md shadow-sm -space-y-px"> | ||||||
|  |             <div> | ||||||
|  |               <label htmlFor="email" className="sr-only"> | ||||||
|  |                 Email address | ||||||
|  |               </label> | ||||||
|  |               <div className="relative"> | ||||||
|  |                 <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||||||
|  |                   <Mail className="h-5 w-5 text-gray-400" /> | ||||||
|  |                 </div> | ||||||
|  |                 <input | ||||||
|  |                   id="email" | ||||||
|  |                   name="email" | ||||||
|  |                   type="email" | ||||||
|  |                   autoComplete="email" | ||||||
|  |                   required | ||||||
|  |                   className="appearance-none rounded-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm" | ||||||
|  |                   placeholder="Email address" | ||||||
|  |                   value={formData.email} | ||||||
|  |                   onChange={handleChange} | ||||||
|  |                 /> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |             <div> | ||||||
|  |               <label htmlFor="password" className="sr-only"> | ||||||
|  |                 Password | ||||||
|  |               </label> | ||||||
|  |               <div className="relative"> | ||||||
|  |                 <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||||||
|  |                   <Lock className="h-5 w-5 text-gray-400" /> | ||||||
|  |                 </div> | ||||||
|  |                 <input | ||||||
|  |                   id="password" | ||||||
|  |                   name="password" | ||||||
|  |                   type={showPassword ? 'text' : 'password'} | ||||||
|  |                   autoComplete="current-password" | ||||||
|  |                   required | ||||||
|  |                   className="appearance-none rounded-none relative block w-full px-3 py-2 pl-10 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm" | ||||||
|  |                   placeholder="Password" | ||||||
|  |                   value={formData.password} | ||||||
|  |                   onChange={handleChange} | ||||||
|  |                 /> | ||||||
|  |                 <div className="absolute inset-y-0 right-0 pr-3 flex items-center"> | ||||||
|  |                   <button | ||||||
|  |                     type="button" | ||||||
|  |                     className="text-gray-400 hover:text-gray-500" | ||||||
|  |                     onClick={() => setShowPassword(!showPassword)} | ||||||
|  |                   > | ||||||
|  |                     {showPassword ? ( | ||||||
|  |                       <EyeOff className="h-5 w-5" /> | ||||||
|  |                     ) : ( | ||||||
|  |                       <Eye className="h-5 w-5" /> | ||||||
|  |                     )} | ||||||
|  |                   </button> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           <div> | ||||||
|  |             <button | ||||||
|  |               type="submit" | ||||||
|  |               disabled={loading} | ||||||
|  |               className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed" | ||||||
|  |             > | ||||||
|  |               {loading ? 'Signing in...' : 'Sign in'} | ||||||
|  |             </button> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           <div className="text-center"> | ||||||
|  |             <p className="text-sm text-gray-600"> | ||||||
|  |               Demo accounts: | ||||||
|  |             </p> | ||||||
|  |             <div className="mt-2 text-xs text-gray-500 space-y-1"> | ||||||
|  |               <p>Admin: admin@mysteryapp.com / admin123</p> | ||||||
|  |               <p>Recruiter: recruiter@mysteryapp.com / recruiter123</p> | ||||||
|  |               <p>Employer: employer@techcorp.com / employer123</p> | ||||||
|  |               <p>Candidate: candidate@example.com / candidate123</p> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </form> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Login; | ||||||
							
								
								
									
										142
									
								
								frontend/src/pages/Profile.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								frontend/src/pages/Profile.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,142 @@ | |||||||
|  | import React, { useState } from 'react'; | ||||||
|  | import { useAuth } from '../contexts/AuthContext'; | ||||||
|  | import { User, Mail, Save } from 'lucide-react'; | ||||||
|  | import toast from 'react-hot-toast'; | ||||||
|  |  | ||||||
|  | const Profile = () => { | ||||||
|  |   const { user, fetchUser } = useAuth(); | ||||||
|  |   const [formData, setFormData] = useState({ | ||||||
|  |     firstName: user?.firstName || '', | ||||||
|  |     lastName: user?.lastName || '', | ||||||
|  |     email: user?.email || '' | ||||||
|  |   }); | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |  | ||||||
|  |   const handleChange = (e) => { | ||||||
|  |     setFormData({ | ||||||
|  |       ...formData, | ||||||
|  |       [e.target.name]: e.target.value | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleSubmit = async (e) => { | ||||||
|  |     e.preventDefault(); | ||||||
|  |     setLoading(true); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       await fetch(`/api/users/${user.id}`, { | ||||||
|  |         method: 'PUT', | ||||||
|  |         headers: { | ||||||
|  |           'Content-Type': 'application/json', | ||||||
|  |           'Authorization': `Bearer ${localStorage.getItem('token')}` | ||||||
|  |         }, | ||||||
|  |         body: JSON.stringify(formData) | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       await fetchUser(); | ||||||
|  |       toast.success('Profile updated successfully!'); | ||||||
|  |     } catch (error) { | ||||||
|  |       toast.error('Failed to update profile'); | ||||||
|  |     } finally { | ||||||
|  |       setLoading(false); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="space-y-6"> | ||||||
|  |       <div> | ||||||
|  |         <h1 className="text-2xl font-bold text-gray-900">Profile</h1> | ||||||
|  |         <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |           Manage your account information | ||||||
|  |         </p> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div className="bg-white shadow rounded-lg"> | ||||||
|  |         <div className="px-4 py-5 sm:p-6"> | ||||||
|  |           <form onSubmit={handleSubmit} className="space-y-6"> | ||||||
|  |             <div className="grid grid-cols-1 gap-6 sm:grid-cols-2"> | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="firstName" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   First Name | ||||||
|  |                 </label> | ||||||
|  |                 <div className="mt-1 relative"> | ||||||
|  |                   <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||||||
|  |                     <User className="h-5 w-5 text-gray-400" /> | ||||||
|  |                   </div> | ||||||
|  |                   <input | ||||||
|  |                     type="text" | ||||||
|  |                     name="firstName" | ||||||
|  |                     id="firstName" | ||||||
|  |                     required | ||||||
|  |                     className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm" | ||||||
|  |                     value={formData.firstName} | ||||||
|  |                     onChange={handleChange} | ||||||
|  |                   /> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |  | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="lastName" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Last Name | ||||||
|  |                 </label> | ||||||
|  |                 <div className="mt-1 relative"> | ||||||
|  |                   <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||||||
|  |                     <User className="h-5 w-5 text-gray-400" /> | ||||||
|  |                   </div> | ||||||
|  |                   <input | ||||||
|  |                     type="text" | ||||||
|  |                     name="lastName" | ||||||
|  |                     id="lastName" | ||||||
|  |                     required | ||||||
|  |                     className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm" | ||||||
|  |                     value={formData.lastName} | ||||||
|  |                     onChange={handleChange} | ||||||
|  |                   /> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div> | ||||||
|  |               <label htmlFor="email" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                 Email Address | ||||||
|  |               </label> | ||||||
|  |               <div className="mt-1 relative"> | ||||||
|  |                 <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||||||
|  |                   <Mail className="h-5 w-5 text-gray-400" /> | ||||||
|  |                 </div> | ||||||
|  |                 <input | ||||||
|  |                   type="email" | ||||||
|  |                   name="email" | ||||||
|  |                   id="email" | ||||||
|  |                   required | ||||||
|  |                   className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm" | ||||||
|  |                   value={formData.email} | ||||||
|  |                   onChange={handleChange} | ||||||
|  |                 /> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div className="bg-gray-50 px-4 py-3 rounded-md"> | ||||||
|  |               <p className="text-sm text-gray-600"> | ||||||
|  |                 <strong>Role:</strong> {user?.role} | ||||||
|  |               </p> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div className="flex justify-end"> | ||||||
|  |               <button | ||||||
|  |                 type="submit" | ||||||
|  |                 disabled={loading} | ||||||
|  |                 className="btn btn-primary disabled:opacity-50" | ||||||
|  |               > | ||||||
|  |                 <Save className="h-4 w-4 mr-2" /> | ||||||
|  |                 {loading ? 'Saving...' : 'Save Changes'} | ||||||
|  |               </button> | ||||||
|  |             </div> | ||||||
|  |           </form> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Profile; | ||||||
							
								
								
									
										242
									
								
								frontend/src/pages/Register.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										242
									
								
								frontend/src/pages/Register.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,242 @@ | |||||||
|  | import React, { useState } from 'react'; | ||||||
|  | import { Link } from 'react-router-dom'; | ||||||
|  | import { useAuth } from '../contexts/AuthContext'; | ||||||
|  | import { Eye, EyeOff, Mail, Lock, User } from 'lucide-react'; | ||||||
|  |  | ||||||
|  | const Register = () => { | ||||||
|  |   const { register } = useAuth(); | ||||||
|  |   const [formData, setFormData] = useState({ | ||||||
|  |     firstName: '', | ||||||
|  |     lastName: '', | ||||||
|  |     email: '', | ||||||
|  |     password: '', | ||||||
|  |     confirmPassword: '', | ||||||
|  |     role: 'candidate' | ||||||
|  |   }); | ||||||
|  |   const [showPassword, setShowPassword] = useState(false); | ||||||
|  |   const [showConfirmPassword, setShowConfirmPassword] = useState(false); | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |  | ||||||
|  |   const handleChange = (e) => { | ||||||
|  |     setFormData({ | ||||||
|  |       ...formData, | ||||||
|  |       [e.target.name]: e.target.value | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleSubmit = async (e) => { | ||||||
|  |     e.preventDefault(); | ||||||
|  |      | ||||||
|  |     if (formData.password !== formData.confirmPassword) { | ||||||
|  |       alert('Passwords do not match'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (formData.password.length < 6) { | ||||||
|  |       alert('Password must be at least 6 characters long'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     setLoading(true); | ||||||
|  |      | ||||||
|  |     const result = await register({ | ||||||
|  |       firstName: formData.firstName, | ||||||
|  |       lastName: formData.lastName, | ||||||
|  |       email: formData.email, | ||||||
|  |       password: formData.password, | ||||||
|  |       role: formData.role | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     if (!result.success) { | ||||||
|  |       setLoading(false); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"> | ||||||
|  |       <div className="max-w-md w-full space-y-8"> | ||||||
|  |         <div> | ||||||
|  |           <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900"> | ||||||
|  |             Create your account | ||||||
|  |           </h2> | ||||||
|  |           <p className="mt-2 text-center text-sm text-gray-600"> | ||||||
|  |             Or{' '} | ||||||
|  |             <Link | ||||||
|  |               to="/login" | ||||||
|  |               className="font-medium text-primary-600 hover:text-primary-500" | ||||||
|  |             > | ||||||
|  |               sign in to your existing account | ||||||
|  |             </Link> | ||||||
|  |           </p> | ||||||
|  |         </div> | ||||||
|  |         <form className="mt-8 space-y-6" onSubmit={handleSubmit}> | ||||||
|  |           <div className="space-y-4"> | ||||||
|  |             <div className="grid grid-cols-2 gap-4"> | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="firstName" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   First Name | ||||||
|  |                 </label> | ||||||
|  |                 <div className="mt-1 relative"> | ||||||
|  |                   <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||||||
|  |                     <User className="h-5 w-5 text-gray-400" /> | ||||||
|  |                   </div> | ||||||
|  |                   <input | ||||||
|  |                     id="firstName" | ||||||
|  |                     name="firstName" | ||||||
|  |                     type="text" | ||||||
|  |                     required | ||||||
|  |                     className="appearance-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" | ||||||
|  |                     placeholder="First name" | ||||||
|  |                     value={formData.firstName} | ||||||
|  |                     onChange={handleChange} | ||||||
|  |                   /> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |               <div> | ||||||
|  |                 <label htmlFor="lastName" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                   Last Name | ||||||
|  |                 </label> | ||||||
|  |                 <div className="mt-1 relative"> | ||||||
|  |                   <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||||||
|  |                     <User className="h-5 w-5 text-gray-400" /> | ||||||
|  |                   </div> | ||||||
|  |                   <input | ||||||
|  |                     id="lastName" | ||||||
|  |                     name="lastName" | ||||||
|  |                     type="text" | ||||||
|  |                     required | ||||||
|  |                     className="appearance-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" | ||||||
|  |                     placeholder="Last name" | ||||||
|  |                     value={formData.lastName} | ||||||
|  |                     onChange={handleChange} | ||||||
|  |                   /> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div> | ||||||
|  |               <label htmlFor="email" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                 Email Address | ||||||
|  |               </label> | ||||||
|  |               <div className="mt-1 relative"> | ||||||
|  |                 <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||||||
|  |                   <Mail className="h-5 w-5 text-gray-400" /> | ||||||
|  |                 </div> | ||||||
|  |                 <input | ||||||
|  |                   id="email" | ||||||
|  |                   name="email" | ||||||
|  |                   type="email" | ||||||
|  |                   autoComplete="email" | ||||||
|  |                   required | ||||||
|  |                   className="appearance-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" | ||||||
|  |                   placeholder="Email address" | ||||||
|  |                   value={formData.email} | ||||||
|  |                   onChange={handleChange} | ||||||
|  |                 /> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div> | ||||||
|  |               <label htmlFor="role" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                 Account Type | ||||||
|  |               </label> | ||||||
|  |               <select | ||||||
|  |                 id="role" | ||||||
|  |                 name="role" | ||||||
|  |                 className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" | ||||||
|  |                 value={formData.role} | ||||||
|  |                 onChange={handleChange} | ||||||
|  |               > | ||||||
|  |                 <option value="candidate">Candidate</option> | ||||||
|  |                 <option value="employer">Employer</option> | ||||||
|  |                 <option value="recruiter">Recruiter</option> | ||||||
|  |               </select> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div> | ||||||
|  |               <label htmlFor="password" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                 Password | ||||||
|  |               </label> | ||||||
|  |               <div className="mt-1 relative"> | ||||||
|  |                 <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||||||
|  |                   <Lock className="h-5 w-5 text-gray-400" /> | ||||||
|  |                 </div> | ||||||
|  |                 <input | ||||||
|  |                   id="password" | ||||||
|  |                   name="password" | ||||||
|  |                   type={showPassword ? 'text' : 'password'} | ||||||
|  |                   autoComplete="new-password" | ||||||
|  |                   required | ||||||
|  |                   className="appearance-none relative block w-full px-3 py-2 pl-10 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" | ||||||
|  |                   placeholder="Password" | ||||||
|  |                   value={formData.password} | ||||||
|  |                   onChange={handleChange} | ||||||
|  |                 /> | ||||||
|  |                 <div className="absolute inset-y-0 right-0 pr-3 flex items-center"> | ||||||
|  |                   <button | ||||||
|  |                     type="button" | ||||||
|  |                     className="text-gray-400 hover:text-gray-500" | ||||||
|  |                     onClick={() => setShowPassword(!showPassword)} | ||||||
|  |                   > | ||||||
|  |                     {showPassword ? ( | ||||||
|  |                       <EyeOff className="h-5 w-5" /> | ||||||
|  |                     ) : ( | ||||||
|  |                       <Eye className="h-5 w-5" /> | ||||||
|  |                     )} | ||||||
|  |                   </button> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div> | ||||||
|  |               <label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                 Confirm Password | ||||||
|  |               </label> | ||||||
|  |               <div className="mt-1 relative"> | ||||||
|  |                 <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | ||||||
|  |                   <Lock className="h-5 w-5 text-gray-400" /> | ||||||
|  |                 </div> | ||||||
|  |                 <input | ||||||
|  |                   id="confirmPassword" | ||||||
|  |                   name="confirmPassword" | ||||||
|  |                   type={showConfirmPassword ? 'text' : 'password'} | ||||||
|  |                   autoComplete="new-password" | ||||||
|  |                   required | ||||||
|  |                   className="appearance-none relative block w-full px-3 py-2 pl-10 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" | ||||||
|  |                   placeholder="Confirm password" | ||||||
|  |                   value={formData.confirmPassword} | ||||||
|  |                   onChange={handleChange} | ||||||
|  |                 /> | ||||||
|  |                 <div className="absolute inset-y-0 right-0 pr-3 flex items-center"> | ||||||
|  |                   <button | ||||||
|  |                     type="button" | ||||||
|  |                     className="text-gray-400 hover:text-gray-500" | ||||||
|  |                     onClick={() => setShowConfirmPassword(!showConfirmPassword)} | ||||||
|  |                   > | ||||||
|  |                     {showConfirmPassword ? ( | ||||||
|  |                       <EyeOff className="h-5 w-5" /> | ||||||
|  |                     ) : ( | ||||||
|  |                       <Eye className="h-5 w-5" /> | ||||||
|  |                     )} | ||||||
|  |                   </button> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           <div> | ||||||
|  |             <button | ||||||
|  |               type="submit" | ||||||
|  |               disabled={loading} | ||||||
|  |               className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed" | ||||||
|  |             > | ||||||
|  |               {loading ? 'Creating account...' : 'Create account'} | ||||||
|  |             </button> | ||||||
|  |           </div> | ||||||
|  |         </form> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Register; | ||||||
							
								
								
									
										245
									
								
								frontend/src/pages/Resumes.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										245
									
								
								frontend/src/pages/Resumes.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,245 @@ | |||||||
|  | import React, { useState } from 'react'; | ||||||
|  | import { useQuery } from 'react-query'; | ||||||
|  | import axios from 'axios'; | ||||||
|  | import { Upload, Download, Trash2, Star, FileText } from 'lucide-react'; | ||||||
|  | import toast from 'react-hot-toast'; | ||||||
|  |  | ||||||
|  | const Resumes = () => { | ||||||
|  |   const [uploading, setUploading] = useState(false); | ||||||
|  |   const [selectedFile, setSelectedFile] = useState(null); | ||||||
|  |  | ||||||
|  |   const { data: resumes, isLoading, refetch } = useQuery('resumes', async () => { | ||||||
|  |     // This would need to be implemented based on the candidate's ID | ||||||
|  |     // For now, return empty array | ||||||
|  |     return []; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const handleFileSelect = (e) => { | ||||||
|  |     const file = e.target.files[0]; | ||||||
|  |     if (file) { | ||||||
|  |       if (file.size > 10 * 1024 * 1024) { // 10MB limit | ||||||
|  |         toast.error('File size must be less than 10MB'); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |        | ||||||
|  |       const allowedTypes = [ | ||||||
|  |         'application/pdf', | ||||||
|  |         'application/msword', | ||||||
|  |         'application/vnd.openxmlformats-officedocument.wordprocessingml.document', | ||||||
|  |         'text/plain' | ||||||
|  |       ]; | ||||||
|  |        | ||||||
|  |       if (!allowedTypes.includes(file.type)) { | ||||||
|  |         toast.error('Only PDF, DOC, DOCX, and TXT files are allowed'); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |        | ||||||
|  |       setSelectedFile(file); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleUpload = async () => { | ||||||
|  |     if (!selectedFile) { | ||||||
|  |       toast.error('Please select a file'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     setUploading(true); | ||||||
|  |     const formData = new FormData(); | ||||||
|  |     formData.append('resume', selectedFile); | ||||||
|  |     formData.append('isPrimary', resumes?.length === 0 ? 'true' : 'false'); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       await axios.post('/api/resumes/upload', formData, { | ||||||
|  |         headers: { | ||||||
|  |           'Content-Type': 'multipart/form-data' | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |        | ||||||
|  |       toast.success('Resume uploaded successfully!'); | ||||||
|  |       setSelectedFile(null); | ||||||
|  |       refetch(); | ||||||
|  |     } catch (error) { | ||||||
|  |       toast.error(error.response?.data?.error || 'Failed to upload resume'); | ||||||
|  |     } finally { | ||||||
|  |       setUploading(false); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleDownload = async (resumeId) => { | ||||||
|  |     try { | ||||||
|  |       const response = await axios.get(`/api/resumes/${resumeId}/download`, { | ||||||
|  |         responseType: 'blob' | ||||||
|  |       }); | ||||||
|  |        | ||||||
|  |       const url = window.URL.createObjectURL(new Blob([response.data])); | ||||||
|  |       const link = document.createElement('a'); | ||||||
|  |       link.href = url; | ||||||
|  |       link.setAttribute('download', response.headers['content-disposition']?.split('filename=')[1] || 'resume.pdf'); | ||||||
|  |       document.body.appendChild(link); | ||||||
|  |       link.click(); | ||||||
|  |       link.remove(); | ||||||
|  |       window.URL.revokeObjectURL(url); | ||||||
|  |     } catch (error) { | ||||||
|  |       toast.error('Failed to download resume'); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleSetPrimary = async (resumeId) => { | ||||||
|  |     try { | ||||||
|  |       await axios.put(`/api/resumes/${resumeId}/primary`); | ||||||
|  |       toast.success('Primary resume updated!'); | ||||||
|  |       refetch(); | ||||||
|  |     } catch (error) { | ||||||
|  |       toast.error('Failed to set primary resume'); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleDelete = async (resumeId) => { | ||||||
|  |     if (!window.confirm('Are you sure you want to delete this resume?')) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       await axios.delete(`/api/resumes/${resumeId}`); | ||||||
|  |       toast.success('Resume deleted successfully!'); | ||||||
|  |       refetch(); | ||||||
|  |     } catch (error) { | ||||||
|  |       toast.error('Failed to delete resume'); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   if (isLoading) { | ||||||
|  |     return ( | ||||||
|  |       <div className="flex items-center justify-center h-64"> | ||||||
|  |         <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="space-y-6"> | ||||||
|  |       <div> | ||||||
|  |         <h1 className="text-2xl font-bold text-gray-900">Resumes</h1> | ||||||
|  |         <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |           Manage your resume files | ||||||
|  |         </p> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {/* Upload Section */} | ||||||
|  |       <div className="bg-white shadow rounded-lg"> | ||||||
|  |         <div className="px-4 py-5 sm:p-6"> | ||||||
|  |           <h2 className="text-lg font-medium text-gray-900 mb-4">Upload Resume</h2> | ||||||
|  |           <div className="space-y-4"> | ||||||
|  |             <div> | ||||||
|  |               <label htmlFor="resume" className="block text-sm font-medium text-gray-700"> | ||||||
|  |                 Select Resume File | ||||||
|  |               </label> | ||||||
|  |               <input | ||||||
|  |                 type="file" | ||||||
|  |                 id="resume" | ||||||
|  |                 accept=".pdf,.doc,.docx,.txt" | ||||||
|  |                 onChange={handleFileSelect} | ||||||
|  |                 className="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100" | ||||||
|  |               /> | ||||||
|  |               <p className="mt-1 text-xs text-gray-500"> | ||||||
|  |                 PDF, DOC, DOCX, or TXT files only. Maximum size: 10MB | ||||||
|  |               </p> | ||||||
|  |             </div> | ||||||
|  |              | ||||||
|  |             {selectedFile && ( | ||||||
|  |               <div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"> | ||||||
|  |                 <div className="flex items-center"> | ||||||
|  |                   <FileText className="h-5 w-5 text-gray-400 mr-2" /> | ||||||
|  |                   <span className="text-sm text-gray-900">{selectedFile.name}</span> | ||||||
|  |                   <span className="ml-2 text-xs text-gray-500"> | ||||||
|  |                     ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB) | ||||||
|  |                   </span> | ||||||
|  |                 </div> | ||||||
|  |                 <button | ||||||
|  |                   onClick={handleUpload} | ||||||
|  |                   disabled={uploading} | ||||||
|  |                   className="btn btn-primary disabled:opacity-50" | ||||||
|  |                 > | ||||||
|  |                   <Upload className="h-4 w-4 mr-2" /> | ||||||
|  |                   {uploading ? 'Uploading...' : 'Upload'} | ||||||
|  |                 </button> | ||||||
|  |               </div> | ||||||
|  |             )} | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {/* Resumes List */} | ||||||
|  |       <div className="space-y-4"> | ||||||
|  |         {resumes?.length > 0 ? ( | ||||||
|  |           resumes.map((resume) => ( | ||||||
|  |             <div key={resume.id} className="bg-white shadow rounded-lg"> | ||||||
|  |               <div className="px-4 py-5 sm:p-6"> | ||||||
|  |                 <div className="flex items-center justify-between"> | ||||||
|  |                   <div className="flex items-center"> | ||||||
|  |                     <FileText className="h-8 w-8 text-gray-400 mr-3" /> | ||||||
|  |                     <div> | ||||||
|  |                       <h3 className="text-lg font-medium text-gray-900"> | ||||||
|  |                         {resume.original_name} | ||||||
|  |                       </h3> | ||||||
|  |                       <div className="flex items-center text-sm text-gray-500"> | ||||||
|  |                         <span>{(resume.file_size / 1024 / 1024).toFixed(2)} MB</span> | ||||||
|  |                         <span className="mx-2">•</span> | ||||||
|  |                         <span>Uploaded {new Date(resume.uploaded_at).toLocaleDateString()}</span> | ||||||
|  |                         {resume.is_primary && ( | ||||||
|  |                           <> | ||||||
|  |                             <span className="mx-2">•</span> | ||||||
|  |                             <span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800"> | ||||||
|  |                               <Star className="h-3 w-3 mr-1" /> | ||||||
|  |                               Primary | ||||||
|  |                             </span> | ||||||
|  |                           </> | ||||||
|  |                         )} | ||||||
|  |                       </div> | ||||||
|  |                     </div> | ||||||
|  |                   </div> | ||||||
|  |                   <div className="flex items-center space-x-2"> | ||||||
|  |                     <button | ||||||
|  |                       onClick={() => handleDownload(resume.id)} | ||||||
|  |                       className="btn btn-secondary" | ||||||
|  |                     > | ||||||
|  |                       <Download className="h-4 w-4 mr-2" /> | ||||||
|  |                       Download | ||||||
|  |                     </button> | ||||||
|  |                     {!resume.is_primary && ( | ||||||
|  |                       <button | ||||||
|  |                         onClick={() => handleSetPrimary(resume.id)} | ||||||
|  |                         className="btn btn-secondary" | ||||||
|  |                       > | ||||||
|  |                         <Star className="h-4 w-4 mr-2" /> | ||||||
|  |                         Set Primary | ||||||
|  |                       </button> | ||||||
|  |                     )} | ||||||
|  |                     <button | ||||||
|  |                       onClick={() => handleDelete(resume.id)} | ||||||
|  |                       className="btn btn-danger" | ||||||
|  |                     > | ||||||
|  |                       <Trash2 className="h-4 w-4 mr-2" /> | ||||||
|  |                       Delete | ||||||
|  |                     </button> | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           )) | ||||||
|  |         ) : ( | ||||||
|  |           <div className="text-center py-12"> | ||||||
|  |             <FileText className="mx-auto h-12 w-12 text-gray-400" /> | ||||||
|  |             <h3 className="mt-2 text-sm font-medium text-gray-900">No resumes uploaded</h3> | ||||||
|  |             <p className="mt-1 text-sm text-gray-500"> | ||||||
|  |               Upload your first resume to get started. | ||||||
|  |             </p> | ||||||
|  |           </div> | ||||||
|  |         )} | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Resumes; | ||||||
							
								
								
									
										25
									
								
								frontend/tailwind.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								frontend/tailwind.config.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | |||||||
|  | /** @type {import('tailwindcss').Config} */ | ||||||
|  | module.exports = { | ||||||
|  |   content: [ | ||||||
|  |     "./src/**/*.{js,jsx,ts,tsx}", | ||||||
|  |   ], | ||||||
|  |   theme: { | ||||||
|  |     extend: { | ||||||
|  |       colors: { | ||||||
|  |         primary: { | ||||||
|  |           50: '#eff6ff', | ||||||
|  |           100: '#dbeafe', | ||||||
|  |           200: '#bfdbfe', | ||||||
|  |           300: '#93c5fd', | ||||||
|  |           400: '#60a5fa', | ||||||
|  |           500: '#3b82f6', | ||||||
|  |           600: '#2563eb', | ||||||
|  |           700: '#1d4ed8', | ||||||
|  |           800: '#1e40af', | ||||||
|  |           900: '#1e3a8a', | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   plugins: [], | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user