From 039d51c4e501ab7e1e07e203d9a4aa765a4bb6be Mon Sep 17 00:00:00 2001 From: ReachableCEO Date: Thu, 16 Oct 2025 17:04:52 -0500 Subject: [PATCH] Initial commit --- .gitignore | 8 + README.md | 241 +++++++++++++ SEED_PROMPT.md | 17 + backend/Dockerfile | 12 + backend/package.json | 36 ++ backend/src/database/connection.js | 18 + backend/src/database/migrate.js | 29 ++ backend/src/database/schema.sql | 141 ++++++++ backend/src/database/seed.js | 128 +++++++ backend/src/middleware/auth.js | 54 +++ backend/src/routes/applications.js | 424 ++++++++++++++++++++++ backend/src/routes/auth.js | 153 ++++++++ backend/src/routes/candidates.js | 374 +++++++++++++++++++ backend/src/routes/employers.js | 256 +++++++++++++ backend/src/routes/jobs.js | 478 +++++++++++++++++++++++++ backend/src/routes/resumes.js | 298 +++++++++++++++ backend/src/routes/users.js | 165 +++++++++ backend/src/server.js | 57 +++ backend/src/tests/auth.test.js | 185 ++++++++++ backend/src/tests/jobs.test.js | 252 +++++++++++++ docker-compose.yml | 58 +++ frontend/Dockerfile | 12 + frontend/package.json | 53 +++ frontend/public/index.html | 20 ++ frontend/src/App.js | 136 +++++++ frontend/src/App.test.js | 43 +++ frontend/src/components/Layout.js | 186 ++++++++++ frontend/src/components/Layout.test.js | 70 ++++ frontend/src/contexts/AuthContext.js | 105 ++++++ frontend/src/index.css | 47 +++ frontend/src/index.js | 11 + frontend/src/pages/Applications.js | 111 ++++++ frontend/src/pages/CandidateDetails.js | 149 ++++++++ frontend/src/pages/Candidates.js | 277 ++++++++++++++ frontend/src/pages/CreateJob.js | 464 ++++++++++++++++++++++++ frontend/src/pages/Dashboard.js | 284 +++++++++++++++ frontend/src/pages/EmployerDetails.js | 111 ++++++ frontend/src/pages/Employers.js | 98 +++++ frontend/src/pages/JobDetails.js | 293 +++++++++++++++ frontend/src/pages/Jobs.js | 295 +++++++++++++++ frontend/src/pages/Login.js | 136 +++++++ frontend/src/pages/Profile.js | 142 ++++++++ frontend/src/pages/Register.js | 242 +++++++++++++ frontend/src/pages/Resumes.js | 245 +++++++++++++ frontend/tailwind.config.js | 25 ++ 45 files changed, 6939 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SEED_PROMPT.md create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/database/connection.js create mode 100644 backend/src/database/migrate.js create mode 100644 backend/src/database/schema.sql create mode 100644 backend/src/database/seed.js create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/routes/applications.js create mode 100644 backend/src/routes/auth.js create mode 100644 backend/src/routes/candidates.js create mode 100644 backend/src/routes/employers.js create mode 100644 backend/src/routes/jobs.js create mode 100644 backend/src/routes/resumes.js create mode 100644 backend/src/routes/users.js create mode 100644 backend/src/server.js create mode 100644 backend/src/tests/auth.test.js create mode 100644 backend/src/tests/jobs.test.js create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/package.json create mode 100644 frontend/public/index.html create mode 100644 frontend/src/App.js create mode 100644 frontend/src/App.test.js create mode 100644 frontend/src/components/Layout.js create mode 100644 frontend/src/components/Layout.test.js create mode 100644 frontend/src/contexts/AuthContext.js create mode 100644 frontend/src/index.css create mode 100644 frontend/src/index.js create mode 100644 frontend/src/pages/Applications.js create mode 100644 frontend/src/pages/CandidateDetails.js create mode 100644 frontend/src/pages/Candidates.js create mode 100644 frontend/src/pages/CreateJob.js create mode 100644 frontend/src/pages/Dashboard.js create mode 100644 frontend/src/pages/EmployerDetails.js create mode 100644 frontend/src/pages/Employers.js create mode 100644 frontend/src/pages/JobDetails.js create mode 100644 frontend/src/pages/Jobs.js create mode 100644 frontend/src/pages/Login.js create mode 100644 frontend/src/pages/Profile.js create mode 100644 frontend/src/pages/Register.js create mode 100644 frontend/src/pages/Resumes.js create mode 100644 frontend/tailwind.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6daa83e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.env +.env.* +*.log +tmp/ +.DS_Store +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd331fb --- /dev/null +++ b/README.md @@ -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 + 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. diff --git a/SEED_PROMPT.md b/SEED_PROMPT.md new file mode 100644 index 0000000..5e5de28 --- /dev/null +++ b/SEED_PROMPT.md @@ -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! \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4fcc392 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 3001 + +CMD ["npm", "run", "dev"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..5506773 --- /dev/null +++ b/backend/package.json @@ -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" +} diff --git a/backend/src/database/connection.js b/backend/src/database/connection.js new file mode 100644 index 0000000..f8ef336 --- /dev/null +++ b/backend/src/database/connection.js @@ -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; diff --git a/backend/src/database/migrate.js b/backend/src/database/migrate.js new file mode 100644 index 0000000..3b61f3b --- /dev/null +++ b/backend/src/database/migrate.js @@ -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; diff --git a/backend/src/database/schema.sql b/backend/src/database/schema.sql new file mode 100644 index 0000000..142cd54 --- /dev/null +++ b/backend/src/database/schema.sql @@ -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(); diff --git a/backend/src/database/seed.js b/backend/src/database/seed.js new file mode 100644 index 0000000..7355a02 --- /dev/null +++ b/backend/src/database/seed.js @@ -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; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..829c165 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -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 +}; diff --git a/backend/src/routes/applications.js b/backend/src/routes/applications.js new file mode 100644 index 0000000..2c2589b --- /dev/null +++ b/backend/src/routes/applications.js @@ -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; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..a034f01 --- /dev/null +++ b/backend/src/routes/auth.js @@ -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; diff --git a/backend/src/routes/candidates.js b/backend/src/routes/candidates.js new file mode 100644 index 0000000..c3ad0c8 --- /dev/null +++ b/backend/src/routes/candidates.js @@ -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; diff --git a/backend/src/routes/employers.js b/backend/src/routes/employers.js new file mode 100644 index 0000000..c996ea3 --- /dev/null +++ b/backend/src/routes/employers.js @@ -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; diff --git a/backend/src/routes/jobs.js b/backend/src/routes/jobs.js new file mode 100644 index 0000000..d5434c6 --- /dev/null +++ b/backend/src/routes/jobs.js @@ -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; diff --git a/backend/src/routes/resumes.js b/backend/src/routes/resumes.js new file mode 100644 index 0000000..3e921c0 --- /dev/null +++ b/backend/src/routes/resumes.js @@ -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; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js new file mode 100644 index 0000000..f38383d --- /dev/null +++ b/backend/src/routes/users.js @@ -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; diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 0000000..0acaefe --- /dev/null +++ b/backend/src/server.js @@ -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; diff --git a/backend/src/tests/auth.test.js b/backend/src/tests/auth.test.js new file mode 100644 index 0000000..7291378 --- /dev/null +++ b/backend/src/tests/auth.test.js @@ -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'); + }); + }); +}); diff --git a/backend/src/tests/jobs.test.js b/backend/src/tests/jobs.test.js new file mode 100644 index 0000000..733a7f9 --- /dev/null +++ b/backend/src/tests/jobs.test.js @@ -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'); + }); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8462093 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..08ff5fb --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a118f65 --- /dev/null +++ b/frontend/package.json @@ -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" +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..a0e8b94 --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + MysteryApp-Cursor + + + +
+ + diff --git a/frontend/src/App.js b/frontend/src/App.js new file mode 100644 index 0000000..436d8f3 --- /dev/null +++ b/frontend/src/App.js @@ -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 ( +
+
+
+ ); + } + + if (!user) { + return ; + } + + if (allowedRoles.length > 0 && !allowedRoles.includes(user.role)) { + return ; + } + + return children; +} + +function AppRoutes() { + const { user } = useAuth(); + + return ( + + : } /> + : } /> + + }> + } /> + + + + } /> + + + + + } /> + + + + } /> + + + + } /> + + + + + } /> + + + + } /> + + + + + } /> + + + + + } /> + + + + } /> + + + + + } /> + + + + + } /> + + + ); +} + +function App() { + return ( + + + +
+ + +
+
+
+
+ ); +} + +export default App; diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js new file mode 100644 index 0000000..c3a11c5 --- /dev/null +++ b/frontend/src/App.test.js @@ -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 }) =>
{children}
, + Routes: ({ children }) =>
{children}
, + Route: ({ children }) =>
{children}
, + Navigate: ({ to }) =>
{to}
, + Outlet: () =>
Outlet
+})); + +// Mock react-query +jest.mock('react-query', () => ({ + QueryClient: jest.fn(() => ({})), + QueryClientProvider: ({ children }) =>
{children}
+})); + +// Mock react-hot-toast +jest.mock('react-hot-toast', () => ({ + Toaster: () =>
Toaster
+})); + +describe('App', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('toaster')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Layout.js b/frontend/src/components/Layout.js new file mode 100644 index 0000000..1693dbd --- /dev/null +++ b/frontend/src/components/Layout.js @@ -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 ( +
+ {/* Mobile sidebar */} +
+
setSidebarOpen(false)} /> +
+
+ +
+
+
+

MysteryApp-Cursor

+
+ +
+
+
+ + {/* Desktop sidebar */} +
+
+
+
+

MysteryApp-Cursor

+
+ +
+
+
+
+
+

+ {user?.firstName} {user?.lastName} +

+

+ {user?.role} +

+
+
+
+
+
+
+ + {/* Main content */} +
+
+ +
+ +
+
+
+ +
+
+
+
+ + {/* Top bar for desktop */} +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ + + Profile + + +
+
+
+
+
+
+
+ ); +}; + +export default Layout; diff --git a/frontend/src/components/Layout.test.js b/frontend/src/components/Layout.test.js new file mode 100644 index 0000000..fb1089d --- /dev/null +++ b/frontend/src/components/Layout.test.js @@ -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 }) => {children}, + Outlet: () =>
Outlet
+})); + +const renderWithRouter = (component) => { + return render( + + {component} + + ); +}; + +describe('Layout', () => { + it('renders the layout with user information', () => { + renderWithRouter(); + + 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(); + + 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(); + + expect(screen.getByText('Logout')).toBeInTheDocument(); + }); + + it('calls logout when logout button is clicked', () => { + renderWithRouter(); + + const logoutButton = screen.getByText('Logout'); + logoutButton.click(); + + expect(mockUseAuth.logout).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/contexts/AuthContext.js b/frontend/src/contexts/AuthContext.js new file mode 100644 index 0000000..a30aacb --- /dev/null +++ b/frontend/src/contexts/AuthContext.js @@ -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 ( + + {children} + + ); +}; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..7f56b9d --- /dev/null +++ b/frontend/src/index.css @@ -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; + } +} diff --git a/frontend/src/index.js b/frontend/src/index.js new file mode 100644 index 0000000..2cb1087 --- /dev/null +++ b/frontend/src/index.js @@ -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( + + + +); diff --git a/frontend/src/pages/Applications.js b/frontend/src/pages/Applications.js new file mode 100644 index 0000000..8786ec1 --- /dev/null +++ b/frontend/src/pages/Applications.js @@ -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 ; + case 'reviewed': return ; + case 'shortlisted': return ; + case 'interviewed': return ; + case 'offered': return ; + case 'rejected': return ; + default: return ; + } + }; + + 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 ( +
+
+
+ ); + } + + return ( +
+
+

Applications

+

+ Track your job applications and their status +

+
+ +
+ {data?.applications?.length > 0 ? ( + data.applications.map((application) => ( +
+
+
+
+
+

+ {application.job_title} +

+ + {getStatusIcon(application.status)} + {application.status} + +
+
+ + {application.company_name} +
+
+ + Applied on {new Date(application.applied_at).toLocaleDateString()} +
+ {application.cover_letter && ( +
+

+ {application.cover_letter} +

+
+ )} + {application.notes && ( +
+

+ Notes: {application.notes} +

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

No applications found

+

+ You haven't applied to any jobs yet. +

+
+ )} +
+
+ ); +}; + +export default Applications; diff --git a/frontend/src/pages/CandidateDetails.js b/frontend/src/pages/CandidateDetails.js new file mode 100644 index 0000000..92c68ce --- /dev/null +++ b/frontend/src/pages/CandidateDetails.js @@ -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 ( +
+
+
+ ); + } + + if (!candidate) { + return ( +
+

Candidate not found

+

+ The candidate you're looking for doesn't exist. +

+
+ ); + } + + return ( +
+
+
+
+
+
+ +
+
+
+

+ {candidate.first_name} {candidate.last_name} +

+

{candidate.email}

+ +
+ {candidate.location && ( +
+ + {candidate.location} +
+ )} + + {candidate.phone && ( +
+ + {candidate.phone} +
+ )} + + {candidate.linkedin_url && ( + + )} + + {candidate.github_url && ( + + )} + + {candidate.portfolio_url && ( + + )} +
+
+
+
+
+ + {candidate.bio && ( +
+
+

About

+

{candidate.bio}

+
+
+ )} + + {candidate.skills && candidate.skills.length > 0 && ( +
+
+

Skills

+
+ {candidate.skills.map((skill, index) => ( + + {skill} + + ))} +
+
+
+ )} + +
+
+
+

Experience Level

+ + {candidate.experience_level || 'Not specified'} + +
+
+ + {candidate.salary_expectation && ( +
+
+

Salary Expectation

+

+ ${candidate.salary_expectation.toLocaleString()} +

+
+
+ )} +
+
+ ); +}; + +export default CandidateDetails; diff --git a/frontend/src/pages/Candidates.js b/frontend/src/pages/Candidates.js new file mode 100644 index 0000000..6020078 --- /dev/null +++ b/frontend/src/pages/Candidates.js @@ -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 ( +
+
+
+ ); + } + + return ( +
+
+

Candidates

+

+ Browse and discover talented candidates +

+
+ + {/* Filters */} +
+
+
+
+
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+ + {/* Candidates List */} +
+ {data?.candidates?.length > 0 ? ( + data.candidates.map((candidate) => ( +
+
+
+
+
+ +
+
+
+

+ + {candidate.first_name} {candidate.last_name} + +

+

{candidate.email}

+ + {candidate.location && ( +
+ + {candidate.location} +
+ )} + + {candidate.experience_level && ( +
+ + {candidate.experience_level} level + +
+ )} + + {candidate.bio && ( +
+

+ {candidate.bio} +

+
+ )} + + {candidate.skills && candidate.skills.length > 0 && ( +
+
+ {candidate.skills.slice(0, 4).map((skill, index) => ( + + {skill} + + ))} + {candidate.skills.length > 4 && ( + + +{candidate.skills.length - 4} more + + )} +
+
+ )} + + {candidate.salary_expectation && ( +
+ + Expected: ${candidate.salary_expectation?.toLocaleString()} +
+ )} +
+
+
+
+ )) + ) : ( +
+ +

No candidates found

+

+ Try adjusting your search criteria +

+
+ )} +
+ + {/* Pagination */} + {data?.pagination && data.pagination.pages > 1 && ( +
+
+ + +
+
+
+

+ Showing{' '} + + {((data.pagination.page - 1) * data.pagination.limit) + 1} + {' '} + to{' '} + + {Math.min(data.pagination.page * data.pagination.limit, data.pagination.total)} + {' '} + of{' '} + {data.pagination.total}{' '} + results +

+
+
+
+ )} +
+ ); +}; + +export default Candidates; diff --git a/frontend/src/pages/CreateJob.js b/frontend/src/pages/CreateJob.js new file mode 100644 index 0000000..0cb76a3 --- /dev/null +++ b/frontend/src/pages/CreateJob.js @@ -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 ( +
+
+ +
+

Post a New Job

+

+ Create a job posting to attract qualified candidates +

+
+
+ +
+ {/* Basic Information */} +
+
+

Basic Information

+
+
+ + +
+ +
+ +