Initial commit

This commit is contained in:
2025-10-16 17:04:52 -05:00
commit 039d51c4e5
45 changed files with 6939 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
dist/
.env
.env.*
*.log
tmp/
.DS_Store
.vscode/

241
README.md Normal file
View File

@@ -0,0 +1,241 @@
# MysteryApp-Cursor - Recruiter Workflow SAAS
A comprehensive SAAS application for managing recruiter workflows, built with modern technologies and following TDD principles.
## Features
### Core Functionality
- **User Management**: Multi-role authentication system (Admin, Recruiter, Employer, Candidate)
- **Employer Management**: Company profiles, job postings, candidate management
- **Candidate Management**: Profile creation, resume uploads, application tracking
- **Job Management**: Job posting creation, filtering, search capabilities
- **Application Tracking**: End-to-end application workflow management
- **Resume Management**: File upload, download, and management system
### User Roles
- **Admin**: Full system access and user management
- **Recruiter**: Candidate and employer management, job posting oversight
- **Employer**: Job posting creation, candidate review, application management
- **Candidate**: Job browsing, application submission, profile management
## Technology Stack
### Backend
- **Node.js** with Express.js
- **PostgreSQL** database
- **JWT** authentication
- **Multer** for file uploads
- **Jest** for testing
- **Docker** containerization
### Frontend
- **React 18** with modern hooks
- **React Router** for navigation
- **React Query** for data fetching
- **Tailwind CSS** for styling
- **Lucide React** for icons
- **React Hot Toast** for notifications
### Infrastructure
- **Docker Compose** for orchestration
- **PostgreSQL** database
- **File upload** handling with proper validation
## Getting Started
### Prerequisites
- Docker and Docker Compose
- Git
### Installation
1. **Clone the repository**
```bash
git clone <repository-url>
cd MysteryApp-Cursor
```
2. **Start the application**
```bash
docker-compose up --build
```
3. **Initialize the database**
```bash
# Run database migrations
docker-compose exec MysteryApp-Cursor-backend npm run migrate
# Seed the database with sample data
docker-compose exec MysteryApp-Cursor-backend npm run seed
```
4. **Access the application**
- Frontend: http://localhost:3000
- Backend API: http://localhost:3001
- Database: localhost:5432
### Demo Accounts
The application comes with pre-seeded demo accounts:
- **Admin**: admin@mysteryapp.com / admin123
- **Recruiter**: recruiter@mysteryapp.com / recruiter123
- **Employer**: employer@techcorp.com / employer123
- **Candidate**: candidate@example.com / candidate123
## API Documentation
### Authentication Endpoints
- `POST /api/auth/register` - User registration
- `POST /api/auth/login` - User login
- `GET /api/auth/me` - Get current user
- `POST /api/auth/logout` - User logout
### User Management
- `GET /api/users` - List all users (Admin only)
- `GET /api/users/:id` - Get user by ID
- `PUT /api/users/:id` - Update user profile
- `PUT /api/users/:id/deactivate` - Deactivate user (Admin only)
### Job Management
- `GET /api/jobs` - List jobs with filtering
- `GET /api/jobs/:id` - Get job details
- `POST /api/jobs` - Create job posting
- `PUT /api/jobs/:id` - Update job posting
- `DELETE /api/jobs/:id` - Delete job posting
### Candidate Management
- `GET /api/candidates` - List candidates with filtering
- `GET /api/candidates/:id` - Get candidate details
- `POST /api/candidates` - Create candidate profile
- `PUT /api/candidates/:id` - Update candidate profile
### Application Management
- `GET /api/applications` - List applications
- `GET /api/applications/:id` - Get application details
- `POST /api/applications` - Submit application
- `PUT /api/applications/:id/status` - Update application status
### Resume Management
- `GET /api/resumes/candidate/:candidateId` - Get candidate resumes
- `POST /api/resumes/upload` - Upload resume
- `GET /api/resumes/:id/download` - Download resume
- `PUT /api/resumes/:id/primary` - Set primary resume
- `DELETE /api/resumes/:id` - Delete resume
## Testing
### Backend Tests
```bash
# Run all tests
docker-compose exec MysteryApp-Cursor-backend npm test
# Run tests in watch mode
docker-compose exec MysteryApp-Cursor-backend npm run test:watch
```
### Frontend Tests
```bash
# Run frontend tests
docker-compose exec MysteryApp-Cursor-frontend npm test
```
## Development
### Project Structure
```
MysteryApp-Cursor/
├── backend/
│ ├── src/
│ │ ├── routes/ # API route handlers
│ │ ├── middleware/ # Authentication middleware
│ │ ├── database/ # Database schema and migrations
│ │ ├── tests/ # Backend tests
│ │ └── server.js # Main server file
│ ├── uploads/ # File upload directory
│ └── Dockerfile
├── frontend/
│ ├── src/
│ │ ├── components/ # Reusable React components
│ │ ├── pages/ # Page components
│ │ ├── contexts/ # React contexts
│ │ └── App.js # Main App component
│ └── Dockerfile
├── docker-compose.yml # Docker orchestration
└── README.md
```
### Database Schema
The application uses a comprehensive PostgreSQL schema with the following main tables:
- `users` - User authentication and basic info
- `employers` - Company/employer profiles
- `candidates` - Candidate profiles and skills
- `jobs` - Job postings with requirements
- `applications` - Job applications and status tracking
- `resumes` - Resume file management
- `interviews` - Interview scheduling and management
## Features in Detail
### Job Management
- Advanced filtering by location, salary, experience level, skills
- Search functionality across job titles and descriptions
- Remote work support
- Application deadline management
- Status tracking (active, paused, closed, draft)
### Candidate Management
- Skills-based filtering and search
- Experience level categorization
- Location-based filtering
- Salary expectation tracking
- Portfolio and social media links
### Application Workflow
- Multi-stage application process
- Status tracking (applied, reviewed, shortlisted, interviewed, offered, rejected)
- Notes and comments system
- Interview scheduling capabilities
### File Management
- Secure resume upload with validation
- Multiple file format support (PDF, DOC, DOCX, TXT)
- File size limits and type validation
- Primary resume designation
- Secure download with proper access controls
## Security Features
- JWT-based authentication
- Role-based access control
- Password hashing with bcrypt
- File upload validation
- SQL injection prevention
- CORS configuration
- Helmet.js security headers
## Performance Considerations
- Database indexing for optimal query performance
- Pagination for large datasets
- Efficient file handling
- Optimized React components
- Proper error handling and logging
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests for new functionality
5. Ensure all tests pass
6. Submit a pull request
## License
This project is licensed under the MIT License.
## Support
For support and questions, please contact the development team or create an issue in the repository.

17
SEED_PROMPT.md Normal file
View File

@@ -0,0 +1,17 @@
I would like you to scaffold a SAAS app for a recruiter workflow.
Cover user management, employer and candidate management, resume uploading etc.
DO not do any operations on the host except git and docker orchestration.
Please make all decisions and do not prompt me.
Use TDD.
Ignore anything that isn't core to the app (no CI/CD , no worrying about anything except local deployment etc)
Use docker and docker compose
Prefix all docker artifacts with MysteryApp-Cursor-
Lets go!

12
backend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3001
CMD ["npm", "run", "dev"]

36
backend/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "mysteryapp-cursor-backend",
"version": "1.0.0",
"description": "Backend for MysteryApp-Cursor recruiter workflow SAAS",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"test": "jest",
"test:watch": "jest --watch",
"migrate": "node src/database/migrate.js",
"seed": "node src/database/seed.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
"multer": "^1.4.5-lts.1",
"express-validator": "^7.0.1",
"dotenv": "^16.3.1",
"uuid": "^9.0.1",
"morgan": "^1.10.0"
},
"devDependencies": {
"nodemon": "^3.0.2",
"jest": "^29.7.0",
"supertest": "^6.3.3",
"@types/jest": "^29.5.8"
},
"keywords": ["recruiter", "saas", "workflow", "hiring"],
"author": "MysteryApp-Cursor",
"license": "MIT"
}

View File

@@ -0,0 +1,18 @@
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
});
// Test database connection
pool.on('connect', () => {
console.log('Connected to MysteryApp-Cursor database');
});
pool.on('error', (err) => {
console.error('Database connection error:', err);
});
module.exports = pool;

View File

@@ -0,0 +1,29 @@
const fs = require('fs');
const path = require('path');
const pool = require('./connection');
async function migrate() {
try {
console.log('Starting database migration...');
// Read schema file
const schemaPath = path.join(__dirname, 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf8');
// Execute schema
await pool.query(schema);
console.log('Database migration completed successfully!');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
await pool.end();
}
}
if (require.main === module) {
migrate();
}
module.exports = migrate;

View File

@@ -0,0 +1,141 @@
-- MysteryApp-Cursor Database Schema
-- Users table (for authentication and user management)
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
role VARCHAR(50) NOT NULL CHECK (role IN ('admin', 'recruiter', 'employer', 'candidate')),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Employers table
CREATE TABLE IF NOT EXISTS employers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
company_name VARCHAR(255) NOT NULL,
industry VARCHAR(100),
company_size VARCHAR(50),
website VARCHAR(255),
description TEXT,
address TEXT,
phone VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Candidates table
CREATE TABLE IF NOT EXISTS candidates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
phone VARCHAR(20),
location VARCHAR(255),
linkedin_url VARCHAR(255),
github_url VARCHAR(255),
portfolio_url VARCHAR(255),
bio TEXT,
skills TEXT[],
experience_level VARCHAR(50),
availability VARCHAR(50),
salary_expectation INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Job postings table
CREATE TABLE IF NOT EXISTS jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
employer_id UUID REFERENCES employers(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
requirements TEXT[],
responsibilities TEXT[],
location VARCHAR(255),
employment_type VARCHAR(50) CHECK (employment_type IN ('full-time', 'part-time', 'contract', 'internship')),
salary_min INTEGER,
salary_max INTEGER,
currency VARCHAR(3) DEFAULT 'USD',
status VARCHAR(50) DEFAULT 'active' CHECK (status IN ('active', 'paused', 'closed', 'draft')),
remote_allowed BOOLEAN DEFAULT false,
experience_level VARCHAR(50),
skills_required TEXT[],
benefits TEXT[],
application_deadline DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Applications table
CREATE TABLE IF NOT EXISTS applications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID REFERENCES jobs(id) ON DELETE CASCADE,
candidate_id UUID REFERENCES candidates(id) ON DELETE CASCADE,
status VARCHAR(50) DEFAULT 'applied' CHECK (status IN ('applied', 'reviewed', 'shortlisted', 'interviewed', 'offered', 'rejected', 'withdrawn')),
cover_letter TEXT,
notes TEXT,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(job_id, candidate_id)
);
-- Resumes table
CREATE TABLE IF NOT EXISTS resumes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
candidate_id UUID REFERENCES candidates(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size INTEGER NOT NULL,
mime_type VARCHAR(100) NOT NULL,
is_primary BOOLEAN DEFAULT false,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Interviews table
CREATE TABLE IF NOT EXISTS interviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
application_id UUID REFERENCES applications(id) ON DELETE CASCADE,
scheduled_at TIMESTAMP NOT NULL,
duration_minutes INTEGER DEFAULT 60,
interview_type VARCHAR(50) CHECK (interview_type IN ('phone', 'video', 'in-person', 'technical')),
location VARCHAR(255),
meeting_link VARCHAR(500),
notes TEXT,
status VARCHAR(50) DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'completed', 'cancelled', 'rescheduled')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
CREATE INDEX IF NOT EXISTS idx_employers_user_id ON employers(user_id);
CREATE INDEX IF NOT EXISTS idx_candidates_user_id ON candidates(user_id);
CREATE INDEX IF NOT EXISTS idx_jobs_employer_id ON jobs(employer_id);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
CREATE INDEX IF NOT EXISTS idx_applications_job_id ON applications(job_id);
CREATE INDEX IF NOT EXISTS idx_applications_candidate_id ON applications(candidate_id);
CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status);
CREATE INDEX IF NOT EXISTS idx_resumes_candidate_id ON resumes(candidate_id);
CREATE INDEX IF NOT EXISTS idx_interviews_application_id ON interviews(application_id);
-- Create updated_at trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Apply updated_at triggers
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_employers_updated_at BEFORE UPDATE ON employers FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_candidates_updated_at BEFORE UPDATE ON candidates FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_jobs_updated_at BEFORE UPDATE ON jobs FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_applications_updated_at BEFORE UPDATE ON applications FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_interviews_updated_at BEFORE UPDATE ON interviews FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,128 @@
const bcrypt = require('bcryptjs');
const pool = require('./connection');
async function seed() {
try {
console.log('Starting database seeding...');
// Hash passwords
const adminPassword = await bcrypt.hash('admin123', 10);
const recruiterPassword = await bcrypt.hash('recruiter123', 10);
const employerPassword = await bcrypt.hash('employer123', 10);
const candidatePassword = await bcrypt.hash('candidate123', 10);
// Insert users
const users = [
{
email: 'admin@mysteryapp.com',
password_hash: adminPassword,
first_name: 'Admin',
last_name: 'User',
role: 'admin'
},
{
email: 'recruiter@mysteryapp.com',
password_hash: recruiterPassword,
first_name: 'John',
last_name: 'Recruiter',
role: 'recruiter'
},
{
email: 'employer@techcorp.com',
password_hash: employerPassword,
first_name: 'Jane',
last_name: 'Smith',
role: 'employer'
},
{
email: 'candidate@example.com',
password_hash: candidatePassword,
first_name: 'Mike',
last_name: 'Johnson',
role: 'candidate'
}
];
const userResults = [];
for (const user of users) {
const result = await pool.query(
'INSERT INTO users (email, password_hash, first_name, last_name, role) VALUES ($1, $2, $3, $4, $5) RETURNING id',
[user.email, user.password_hash, user.first_name, user.last_name, user.role]
);
userResults.push({ ...user, id: result.rows[0].id });
}
// Insert employer
const employerResult = await pool.query(
`INSERT INTO employers (user_id, company_name, industry, company_size, website, description, address, phone)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
[
userResults[2].id, // employer user
'TechCorp Solutions',
'Technology',
'50-200',
'https://techcorp.com',
'Leading technology company specializing in innovative software solutions.',
'123 Tech Street, San Francisco, CA 94105',
'+1-555-0123'
]
);
// Insert candidate
await pool.query(
`INSERT INTO candidates (user_id, phone, location, linkedin_url, bio, skills, experience_level, availability, salary_expectation)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
userResults[3].id, // candidate user
'+1-555-0456',
'San Francisco, CA',
'https://linkedin.com/in/mikejohnson',
'Experienced software developer with 5+ years in full-stack development.',
['JavaScript', 'React', 'Node.js', 'Python', 'PostgreSQL'],
'senior',
'immediately',
120000
]
);
// Insert job posting
const jobResult = await pool.query(
`INSERT INTO jobs (employer_id, title, description, requirements, responsibilities, location, employment_type, salary_min, salary_max, remote_allowed, experience_level, skills_required, benefits)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`,
[
employerResult.rows[0].id,
'Senior Full Stack Developer',
'We are looking for a talented Senior Full Stack Developer to join our growing team.',
['5+ years of experience', 'Bachelor degree in Computer Science', 'Strong problem-solving skills'],
['Develop web applications', 'Collaborate with team members', 'Code reviews', 'Mentor junior developers'],
'San Francisco, CA',
'full-time',
100000,
150000,
true,
'senior',
['JavaScript', 'React', 'Node.js', 'PostgreSQL', 'AWS'],
['Health insurance', '401k', 'Flexible work hours', 'Remote work']
]
);
console.log('Database seeding completed successfully!');
console.log('Sample users created:');
console.log('- Admin: admin@mysteryapp.com / admin123');
console.log('- Recruiter: recruiter@mysteryapp.com / recruiter123');
console.log('- Employer: employer@techcorp.com / employer123');
console.log('- Candidate: candidate@example.com / candidate123');
} catch (error) {
console.error('Seeding failed:', error);
process.exit(1);
} finally {
await pool.end();
}
}
if (require.main === module) {
seed();
}
module.exports = seed;

View File

@@ -0,0 +1,54 @@
const jwt = require('jsonwebtoken');
const pool = require('../database/connection');
const authenticateToken = async (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Get user details from database
const userResult = await pool.query(
'SELECT id, email, first_name, last_name, role, is_active FROM users WHERE id = $1',
[decoded.userId]
);
if (userResult.rows.length === 0) {
return res.status(401).json({ error: 'Invalid token' });
}
const user = userResult.rows[0];
if (!user.is_active) {
return res.status(401).json({ error: 'Account deactivated' });
}
req.user = user;
next();
} catch (error) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
};
const requireRole = (roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
};
module.exports = {
authenticateToken,
requireRole
};

View File

@@ -0,0 +1,424 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const pool = require('../database/connection');
const { authenticateToken, requireRole } = require('../middleware/auth');
const router = express.Router();
// Get all applications (with filtering)
router.get('/', authenticateToken, async (req, res) => {
try {
const {
jobId,
candidateId,
status,
employerId,
page = 1,
limit = 10
} = req.query;
let query = `
SELECT a.*,
j.title as job_title,
j.employer_id,
e.company_name,
c.user_id as candidate_user_id,
u.first_name,
u.last_name,
u.email as candidate_email
FROM applications a
JOIN jobs j ON a.job_id = j.id
JOIN employers e ON j.employer_id = e.id
JOIN candidates c ON a.candidate_id = c.id
JOIN users u ON c.user_id = u.id
`;
const queryParams = [];
let paramCount = 0;
const conditions = [];
if (jobId) {
paramCount++;
conditions.push(`a.job_id = $${paramCount}`);
queryParams.push(jobId);
}
if (candidateId) {
paramCount++;
conditions.push(`a.candidate_id = $${paramCount}`);
queryParams.push(candidateId);
}
if (status) {
paramCount++;
conditions.push(`a.status = $${paramCount}`);
queryParams.push(status);
}
if (employerId) {
paramCount++;
conditions.push(`j.employer_id = $${paramCount}`);
queryParams.push(employerId);
}
// Role-based filtering
if (req.user.role === 'candidate') {
// Candidates can only see their own applications
const candidateResult = await pool.query(
'SELECT id FROM candidates WHERE user_id = $1',
[req.user.id]
);
if (candidateResult.rows.length > 0) {
paramCount++;
conditions.push(`a.candidate_id = $${paramCount}`);
queryParams.push(candidateResult.rows[0].id);
}
} else if (req.user.role === 'employer') {
// Employers can only see applications for their jobs
const employerResult = await pool.query(
'SELECT id FROM employers WHERE user_id = $1',
[req.user.id]
);
if (employerResult.rows.length > 0) {
paramCount++;
conditions.push(`j.employer_id = $${paramCount}`);
queryParams.push(employerResult.rows[0].id);
}
}
if (conditions.length > 0) {
query += ` WHERE ${conditions.join(' AND ')}`;
}
query += ` ORDER BY a.applied_at DESC`;
// Add pagination
const offset = (page - 1) * limit;
paramCount++;
query += ` LIMIT $${paramCount}`;
queryParams.push(limit);
paramCount++;
query += ` OFFSET $${paramCount}`;
queryParams.push(offset);
const result = await pool.query(query, queryParams);
// Get total count for pagination
let countQuery = `
SELECT COUNT(*)
FROM applications a
JOIN jobs j ON a.job_id = j.id
JOIN employers e ON j.employer_id = e.id
JOIN candidates c ON a.candidate_id = c.id
`;
const countParams = [];
let countParamCount = 0;
const countConditions = [];
if (jobId) {
countParamCount++;
countConditions.push(`a.job_id = $${countParamCount}`);
countParams.push(jobId);
}
if (candidateId) {
countParamCount++;
countConditions.push(`a.candidate_id = $${countParamCount}`);
countParams.push(candidateId);
}
if (status) {
countParamCount++;
countConditions.push(`a.status = $${countParamCount}`);
countParams.push(status);
}
if (employerId) {
countParamCount++;
countConditions.push(`j.employer_id = $${countParamCount}`);
countParams.push(employerId);
}
// Role-based filtering for count
if (req.user.role === 'candidate') {
const candidateResult = await pool.query(
'SELECT id FROM candidates WHERE user_id = $1',
[req.user.id]
);
if (candidateResult.rows.length > 0) {
countParamCount++;
countConditions.push(`a.candidate_id = $${countParamCount}`);
countParams.push(candidateResult.rows[0].id);
}
} else if (req.user.role === 'employer') {
const employerResult = await pool.query(
'SELECT id FROM employers WHERE user_id = $1',
[req.user.id]
);
if (employerResult.rows.length > 0) {
countParamCount++;
countConditions.push(`j.employer_id = $${countParamCount}`);
countParams.push(employerResult.rows[0].id);
}
}
if (countConditions.length > 0) {
countQuery += ` WHERE ${countConditions.join(' AND ')}`;
}
const countResult = await pool.query(countQuery, countParams);
res.json({
applications: result.rows,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: parseInt(countResult.rows[0].count),
pages: Math.ceil(countResult.rows[0].count / limit)
}
});
} catch (error) {
console.error('Get applications error:', error);
res.status(500).json({ error: 'Failed to fetch applications' });
}
});
// Get application by ID
router.get('/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(`
SELECT a.*,
j.title as job_title,
j.description as job_description,
j.employer_id,
e.company_name,
c.user_id as candidate_user_id,
u.first_name,
u.last_name,
u.email as candidate_email
FROM applications a
JOIN jobs j ON a.job_id = j.id
JOIN employers e ON j.employer_id = e.id
JOIN candidates c ON a.candidate_id = c.id
JOIN users u ON c.user_id = u.id
WHERE a.id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Application not found' });
}
const application = result.rows[0];
// Check permissions
if (req.user.role === 'candidate') {
const candidateResult = await pool.query(
'SELECT id FROM candidates WHERE user_id = $1',
[req.user.id]
);
if (candidateResult.rows.length === 0 || application.candidate_id !== candidateResult.rows[0].id) {
return res.status(403).json({ error: 'Access denied' });
}
} else if (req.user.role === 'employer') {
const employerResult = await pool.query(
'SELECT id FROM employers WHERE user_id = $1',
[req.user.id]
);
if (employerResult.rows.length === 0 || application.employer_id !== employerResult.rows[0].id) {
return res.status(403).json({ error: 'Access denied' });
}
}
res.json(application);
} catch (error) {
console.error('Get application error:', error);
res.status(500).json({ error: 'Failed to fetch application' });
}
});
// Create application
router.post('/', authenticateToken, requireRole(['candidate']), [
body('jobId').isUUID(),
body('coverLetter').optional().trim(),
body('notes').optional().trim()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { jobId, coverLetter, notes } = req.body;
// Get candidate ID for the current user
const candidateResult = await pool.query(
'SELECT id FROM candidates WHERE user_id = $1',
[req.user.id]
);
if (candidateResult.rows.length === 0) {
return res.status(400).json({ error: 'Candidate profile not found' });
}
const candidateId = candidateResult.rows[0].id;
// Check if job exists and is active
const jobResult = await pool.query(
'SELECT id, status FROM jobs WHERE id = $1',
[jobId]
);
if (jobResult.rows.length === 0) {
return res.status(404).json({ error: 'Job not found' });
}
if (jobResult.rows[0].status !== 'active') {
return res.status(400).json({ error: 'Job is not accepting applications' });
}
// Check if application already exists
const existingApplication = await pool.query(
'SELECT id FROM applications WHERE job_id = $1 AND candidate_id = $2',
[jobId, candidateId]
);
if (existingApplication.rows.length > 0) {
return res.status(400).json({ error: 'Application already exists' });
}
const result = await pool.query(`
INSERT INTO applications (job_id, candidate_id, cover_letter, notes)
VALUES ($1, $2, $3, $4)
RETURNING *
`, [jobId, candidateId, coverLetter, notes]);
res.status(201).json({
message: 'Application submitted successfully',
application: result.rows[0]
});
} catch (error) {
console.error('Create application error:', error);
res.status(500).json({ error: 'Failed to submit application' });
}
});
// Update application status
router.put('/:id/status', authenticateToken, [
body('status').isIn(['applied', 'reviewed', 'shortlisted', 'interviewed', 'offered', 'rejected', 'withdrawn'])
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { status } = req.body;
// Check if application exists and user has permission
const applicationResult = await pool.query(`
SELECT a.*, j.employer_id, e.user_id as employer_user_id
FROM applications a
JOIN jobs j ON a.job_id = j.id
JOIN employers e ON j.employer_id = e.id
WHERE a.id = $1
`, [id]);
if (applicationResult.rows.length === 0) {
return res.status(404).json({ error: 'Application not found' });
}
const application = applicationResult.rows[0];
// Check permissions
if (req.user.role === 'candidate') {
const candidateResult = await pool.query(
'SELECT id FROM candidates WHERE user_id = $1',
[req.user.id]
);
if (candidateResult.rows.length === 0 || application.candidate_id !== candidateResult.rows[0].id) {
return res.status(403).json({ error: 'Access denied' });
}
// Candidates can only withdraw their applications
if (status !== 'withdrawn') {
return res.status(403).json({ error: 'Candidates can only withdraw applications' });
}
} else if (req.user.role === 'employer') {
if (application.employer_user_id !== req.user.id) {
return res.status(403).json({ error: 'Access denied' });
}
} else if (req.user.role !== 'admin' && req.user.role !== 'recruiter') {
return res.status(403).json({ error: 'Access denied' });
}
const result = await pool.query(
'UPDATE applications SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *',
[status, id]
);
res.json({
message: 'Application status updated successfully',
application: result.rows[0]
});
} catch (error) {
console.error('Update application status error:', error);
res.status(500).json({ error: 'Failed to update application status' });
}
});
// Update application notes
router.put('/:id/notes', authenticateToken, [
body('notes').notEmpty().trim()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { notes } = req.body;
// Check if application exists and user has permission
const applicationResult = await pool.query(`
SELECT a.*, j.employer_id, e.user_id as employer_user_id
FROM applications a
JOIN jobs j ON a.job_id = j.id
JOIN employers e ON j.employer_id = e.id
WHERE a.id = $1
`, [id]);
if (applicationResult.rows.length === 0) {
return res.status(404).json({ error: 'Application not found' });
}
const application = applicationResult.rows[0];
// Check permissions (employers, recruiters, and admins can add notes)
if (req.user.role === 'candidate') {
return res.status(403).json({ error: 'Candidates cannot add notes to applications' });
} else if (req.user.role === 'employer') {
if (application.employer_user_id !== req.user.id) {
return res.status(403).json({ error: 'Access denied' });
}
} else if (req.user.role !== 'admin' && req.user.role !== 'recruiter') {
return res.status(403).json({ error: 'Access denied' });
}
const result = await pool.query(
'UPDATE applications SET notes = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *',
[notes, id]
);
res.json({
message: 'Application notes updated successfully',
application: result.rows[0]
});
} catch (error) {
console.error('Update application notes error:', error);
res.status(500).json({ error: 'Failed to update application notes' });
}
});
module.exports = router;

153
backend/src/routes/auth.js Normal file
View File

@@ -0,0 +1,153 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const pool = require('../database/connection');
const { authenticateToken } = require('../middleware/auth');
const router = express.Router();
// Register
router.post('/register', [
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 6 }),
body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim(),
body('role').isIn(['recruiter', 'employer', 'candidate'])
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { email, password, firstName, lastName, role } = req.body;
// Check if user already exists
const existingUser = await pool.query(
'SELECT id FROM users WHERE email = $1',
[email]
);
if (existingUser.rows.length > 0) {
return res.status(400).json({ error: 'User already exists' });
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Create user
const userResult = await pool.query(
'INSERT INTO users (email, password_hash, first_name, last_name, role) VALUES ($1, $2, $3, $4, $5) RETURNING id, email, first_name, last_name, role',
[email, passwordHash, firstName, lastName, role]
);
const user = userResult.rows[0];
// Generate JWT token
const token = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.status(201).json({
message: 'User created successfully',
token,
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role
}
});
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({ error: 'Registration failed' });
}
});
// Login
router.post('/login', [
body('email').isEmail().normalizeEmail(),
body('password').notEmpty()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { email, password } = req.body;
// Get user
const userResult = await pool.query(
'SELECT id, email, password_hash, first_name, last_name, role, is_active FROM users WHERE email = $1',
[email]
);
if (userResult.rows.length === 0) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = userResult.rows[0];
if (!user.is_active) {
return res.status(401).json({ error: 'Account deactivated' });
}
// Verify password
const isValidPassword = await bcrypt.compare(password, user.password_hash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate JWT token
const token = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
message: 'Login successful',
token,
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role
}
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
// Get current user
router.get('/me', authenticateToken, async (req, res) => {
try {
res.json({
user: {
id: req.user.id,
email: req.user.email,
firstName: req.user.first_name,
lastName: req.user.last_name,
role: req.user.role
}
});
} catch (error) {
console.error('Get user error:', error);
res.status(500).json({ error: 'Failed to get user information' });
}
});
// Logout (client-side token removal)
router.post('/logout', authenticateToken, (req, res) => {
res.json({ message: 'Logout successful' });
});
module.exports = router;

View File

@@ -0,0 +1,374 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const pool = require('../database/connection');
const { authenticateToken, requireRole } = require('../middleware/auth');
const router = express.Router();
// Get all candidates
router.get('/', authenticateToken, async (req, res) => {
try {
const { skills, experienceLevel, location, page = 1, limit = 10 } = req.query;
let query = `
SELECT c.*, u.email, u.first_name, u.last_name
FROM candidates c
JOIN users u ON c.user_id = u.id
`;
const queryParams = [];
let paramCount = 0;
const conditions = [];
if (skills) {
const skillArray = skills.split(',').map(s => s.trim());
paramCount++;
conditions.push(`c.skills && $${paramCount}`);
queryParams.push(skillArray);
}
if (experienceLevel) {
paramCount++;
conditions.push(`c.experience_level = $${paramCount}`);
queryParams.push(experienceLevel);
}
if (location) {
paramCount++;
conditions.push(`c.location ILIKE $${paramCount}`);
queryParams.push(`%${location}%`);
}
if (conditions.length > 0) {
query += ` WHERE ${conditions.join(' AND ')}`;
}
query += ` ORDER BY c.created_at DESC`;
// Add pagination
const offset = (page - 1) * limit;
paramCount++;
query += ` LIMIT $${paramCount}`;
queryParams.push(limit);
paramCount++;
query += ` OFFSET $${paramCount}`;
queryParams.push(offset);
const result = await pool.query(query, queryParams);
// Get total count for pagination
let countQuery = `
SELECT COUNT(*)
FROM candidates c
JOIN users u ON c.user_id = u.id
`;
const countParams = [];
let countParamCount = 0;
const countConditions = [];
if (skills) {
const skillArray = skills.split(',').map(s => s.trim());
countParamCount++;
countConditions.push(`c.skills && $${countParamCount}`);
countParams.push(skillArray);
}
if (experienceLevel) {
countParamCount++;
countConditions.push(`c.experience_level = $${countParamCount}`);
countParams.push(experienceLevel);
}
if (location) {
countParamCount++;
countConditions.push(`c.location ILIKE $${countParamCount}`);
countParams.push(`%${location}%`);
}
if (countConditions.length > 0) {
countQuery += ` WHERE ${countConditions.join(' AND ')}`;
}
const countResult = await pool.query(countQuery, countParams);
res.json({
candidates: result.rows,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: parseInt(countResult.rows[0].count),
pages: Math.ceil(countResult.rows[0].count / limit)
}
});
} catch (error) {
console.error('Get candidates error:', error);
res.status(500).json({ error: 'Failed to fetch candidates' });
}
});
// Get candidate by ID
router.get('/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(`
SELECT c.*, u.email, u.first_name, u.last_name
FROM candidates c
JOIN users u ON c.user_id = u.id
WHERE c.id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Candidate not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Get candidate error:', error);
res.status(500).json({ error: 'Failed to fetch candidate' });
}
});
// Create candidate profile
router.post('/', authenticateToken, requireRole(['candidate']), [
body('phone').optional().trim(),
body('location').optional().trim(),
body('linkedinUrl').optional().isURL(),
body('githubUrl').optional().isURL(),
body('portfolioUrl').optional().isURL(),
body('bio').optional().trim(),
body('skills').optional().isArray(),
body('experienceLevel').optional().isIn(['entry', 'mid', 'senior', 'lead', 'executive']),
body('availability').optional().trim(),
body('salaryExpectation').optional().isInt({ min: 0 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
phone,
location,
linkedinUrl,
githubUrl,
portfolioUrl,
bio,
skills,
experienceLevel,
availability,
salaryExpectation
} = req.body;
// Check if candidate profile already exists for this user
const existingCandidate = await pool.query(
'SELECT id FROM candidates WHERE user_id = $1',
[req.user.id]
);
if (existingCandidate.rows.length > 0) {
return res.status(400).json({ error: 'Candidate profile already exists' });
}
const result = await pool.query(`
INSERT INTO candidates (user_id, phone, location, linkedin_url, github_url, portfolio_url, bio, skills, experience_level, availability, salary_expectation)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`, [req.user.id, phone, location, linkedinUrl, githubUrl, portfolioUrl, bio, skills, experienceLevel, availability, salaryExpectation]);
res.status(201).json({
message: 'Candidate profile created successfully',
candidate: result.rows[0]
});
} catch (error) {
console.error('Create candidate error:', error);
res.status(500).json({ error: 'Failed to create candidate profile' });
}
});
// Update candidate profile
router.put('/:id', authenticateToken, [
body('phone').optional().trim(),
body('location').optional().trim(),
body('linkedinUrl').optional().isURL(),
body('githubUrl').optional().isURL(),
body('portfolioUrl').optional().isURL(),
body('bio').optional().trim(),
body('skills').optional().isArray(),
body('experienceLevel').optional().isIn(['entry', 'mid', 'senior', 'lead', 'executive']),
body('availability').optional().trim(),
body('salaryExpectation').optional().isInt({ min: 0 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const {
phone,
location,
linkedinUrl,
githubUrl,
portfolioUrl,
bio,
skills,
experienceLevel,
availability,
salaryExpectation
} = req.body;
// Check if candidate exists and user has permission
const candidateResult = await pool.query(
'SELECT user_id FROM candidates WHERE id = $1',
[id]
);
if (candidateResult.rows.length === 0) {
return res.status(404).json({ error: 'Candidate not found' });
}
// Users can only update their own candidate profile unless they're admin
if (candidateResult.rows[0].user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied' });
}
const updateFields = [];
const updateValues = [];
let paramCount = 1;
if (phone !== undefined) {
updateFields.push(`phone = $${paramCount}`);
updateValues.push(phone);
paramCount++;
}
if (location !== undefined) {
updateFields.push(`location = $${paramCount}`);
updateValues.push(location);
paramCount++;
}
if (linkedinUrl !== undefined) {
updateFields.push(`linkedin_url = $${paramCount}`);
updateValues.push(linkedinUrl);
paramCount++;
}
if (githubUrl !== undefined) {
updateFields.push(`github_url = $${paramCount}`);
updateValues.push(githubUrl);
paramCount++;
}
if (portfolioUrl !== undefined) {
updateFields.push(`portfolio_url = $${paramCount}`);
updateValues.push(portfolioUrl);
paramCount++;
}
if (bio !== undefined) {
updateFields.push(`bio = $${paramCount}`);
updateValues.push(bio);
paramCount++;
}
if (skills !== undefined) {
updateFields.push(`skills = $${paramCount}`);
updateValues.push(skills);
paramCount++;
}
if (experienceLevel !== undefined) {
updateFields.push(`experience_level = $${paramCount}`);
updateValues.push(experienceLevel);
paramCount++;
}
if (availability !== undefined) {
updateFields.push(`availability = $${paramCount}`);
updateValues.push(availability);
paramCount++;
}
if (salaryExpectation !== undefined) {
updateFields.push(`salary_expectation = $${paramCount}`);
updateValues.push(salaryExpectation);
paramCount++;
}
if (updateFields.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
updateValues.push(id);
const query = `UPDATE candidates SET ${updateFields.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = $${paramCount} RETURNING *`;
const result = await pool.query(query, updateValues);
res.json({
message: 'Candidate profile updated successfully',
candidate: result.rows[0]
});
} catch (error) {
console.error('Update candidate error:', error);
res.status(500).json({ error: 'Failed to update candidate profile' });
}
});
// Get candidate's applications
router.get('/:id/applications', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const { status, page = 1, limit = 10 } = req.query;
let query = `
SELECT a.*, j.title as job_title, j.employer_id, e.company_name
FROM applications a
JOIN jobs j ON a.job_id = j.id
JOIN employers e ON j.employer_id = e.id
WHERE a.candidate_id = $1
`;
const queryParams = [id];
let paramCount = 1;
if (status) {
paramCount++;
query += ` AND a.status = $${paramCount}`;
queryParams.push(status);
}
query += ` ORDER BY a.applied_at DESC`;
// Add pagination
const offset = (page - 1) * limit;
paramCount++;
query += ` LIMIT $${paramCount}`;
queryParams.push(limit);
paramCount++;
query += ` OFFSET $${paramCount}`;
queryParams.push(offset);
const result = await pool.query(query, queryParams);
// Get total count for pagination
let countQuery = `
SELECT COUNT(*)
FROM applications a
WHERE a.candidate_id = $1
`;
const countParams = [id];
if (status) {
countQuery += ' AND a.status = $2';
countParams.push(status);
}
const countResult = await pool.query(countQuery, countParams);
res.json({
applications: result.rows,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: parseInt(countResult.rows[0].count),
pages: Math.ceil(countResult.rows[0].count / limit)
}
});
} catch (error) {
console.error('Get candidate applications error:', error);
res.status(500).json({ error: 'Failed to fetch candidate applications' });
}
});
module.exports = router;

View File

@@ -0,0 +1,256 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const pool = require('../database/connection');
const { authenticateToken, requireRole } = require('../middleware/auth');
const router = express.Router();
// Get all employers
router.get('/', authenticateToken, async (req, res) => {
try {
const result = await pool.query(`
SELECT e.*, u.email, u.first_name, u.last_name
FROM employers e
JOIN users u ON e.user_id = u.id
ORDER BY e.created_at DESC
`);
res.json(result.rows);
} catch (error) {
console.error('Get employers error:', error);
res.status(500).json({ error: 'Failed to fetch employers' });
}
});
// Get employer by ID
router.get('/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(`
SELECT e.*, u.email, u.first_name, u.last_name
FROM employers e
JOIN users u ON e.user_id = u.id
WHERE e.id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Employer not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Get employer error:', error);
res.status(500).json({ error: 'Failed to fetch employer' });
}
});
// Create employer profile
router.post('/', authenticateToken, requireRole(['employer']), [
body('companyName').notEmpty().trim(),
body('industry').optional().trim(),
body('companySize').optional().trim(),
body('website').optional().isURL(),
body('description').optional().trim(),
body('address').optional().trim(),
body('phone').optional().trim()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
companyName,
industry,
companySize,
website,
description,
address,
phone
} = req.body;
// Check if employer profile already exists for this user
const existingEmployer = await pool.query(
'SELECT id FROM employers WHERE user_id = $1',
[req.user.id]
);
if (existingEmployer.rows.length > 0) {
return res.status(400).json({ error: 'Employer profile already exists' });
}
const result = await pool.query(`
INSERT INTO employers (user_id, company_name, industry, company_size, website, description, address, phone)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`, [req.user.id, companyName, industry, companySize, website, description, address, phone]);
res.status(201).json({
message: 'Employer profile created successfully',
employer: result.rows[0]
});
} catch (error) {
console.error('Create employer error:', error);
res.status(500).json({ error: 'Failed to create employer profile' });
}
});
// Update employer profile
router.put('/:id', authenticateToken, [
body('companyName').optional().notEmpty().trim(),
body('industry').optional().trim(),
body('companySize').optional().trim(),
body('website').optional().isURL(),
body('description').optional().trim(),
body('address').optional().trim(),
body('phone').optional().trim()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const {
companyName,
industry,
companySize,
website,
description,
address,
phone
} = req.body;
// Check if employer exists and user has permission
const employerResult = await pool.query(
'SELECT user_id FROM employers WHERE id = $1',
[id]
);
if (employerResult.rows.length === 0) {
return res.status(404).json({ error: 'Employer not found' });
}
// Users can only update their own employer profile unless they're admin
if (employerResult.rows[0].user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied' });
}
const updateFields = [];
const updateValues = [];
let paramCount = 1;
if (companyName) {
updateFields.push(`company_name = $${paramCount}`);
updateValues.push(companyName);
paramCount++;
}
if (industry !== undefined) {
updateFields.push(`industry = $${paramCount}`);
updateValues.push(industry);
paramCount++;
}
if (companySize !== undefined) {
updateFields.push(`company_size = $${paramCount}`);
updateValues.push(companySize);
paramCount++;
}
if (website !== undefined) {
updateFields.push(`website = $${paramCount}`);
updateValues.push(website);
paramCount++;
}
if (description !== undefined) {
updateFields.push(`description = $${paramCount}`);
updateValues.push(description);
paramCount++;
}
if (address !== undefined) {
updateFields.push(`address = $${paramCount}`);
updateValues.push(address);
paramCount++;
}
if (phone !== undefined) {
updateFields.push(`phone = $${paramCount}`);
updateValues.push(phone);
paramCount++;
}
if (updateFields.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
updateValues.push(id);
const query = `UPDATE employers SET ${updateFields.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = $${paramCount} RETURNING *`;
const result = await pool.query(query, updateValues);
res.json({
message: 'Employer profile updated successfully',
employer: result.rows[0]
});
} catch (error) {
console.error('Update employer error:', error);
res.status(500).json({ error: 'Failed to update employer profile' });
}
});
// Get employer's jobs
router.get('/:id/jobs', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const { status, page = 1, limit = 10 } = req.query;
let query = `
SELECT * FROM jobs
WHERE employer_id = $1
`;
const queryParams = [id];
let paramCount = 1;
if (status) {
paramCount++;
query += ` AND status = $${paramCount}`;
queryParams.push(status);
}
query += ` ORDER BY created_at DESC`;
// Add pagination
const offset = (page - 1) * limit;
paramCount++;
query += ` LIMIT $${paramCount}`;
queryParams.push(limit);
paramCount++;
query += ` OFFSET $${paramCount}`;
queryParams.push(offset);
const result = await pool.query(query, queryParams);
// Get total count for pagination
let countQuery = 'SELECT COUNT(*) FROM jobs WHERE employer_id = $1';
const countParams = [id];
if (status) {
countQuery += ' AND status = $2';
countParams.push(status);
}
const countResult = await pool.query(countQuery, countParams);
res.json({
jobs: result.rows,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: parseInt(countResult.rows[0].count),
pages: Math.ceil(countResult.rows[0].count / limit)
}
});
} catch (error) {
console.error('Get employer jobs error:', error);
res.status(500).json({ error: 'Failed to fetch employer jobs' });
}
});
module.exports = router;

478
backend/src/routes/jobs.js Normal file
View File

@@ -0,0 +1,478 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const pool = require('../database/connection');
const { authenticateToken, requireRole } = require('../middleware/auth');
const router = express.Router();
// Get all jobs with filtering and search
router.get('/', authenticateToken, async (req, res) => {
try {
const {
search,
location,
employmentType,
experienceLevel,
skills,
salaryMin,
salaryMax,
remoteAllowed,
status = 'active',
page = 1,
limit = 10
} = req.query;
let query = `
SELECT j.*, e.company_name, e.industry, e.company_size
FROM jobs j
JOIN employers e ON j.employer_id = e.id
`;
const queryParams = [];
let paramCount = 0;
const conditions = [];
// Always filter by status
paramCount++;
conditions.push(`j.status = $${paramCount}`);
queryParams.push(status);
if (search) {
paramCount++;
conditions.push(`(j.title ILIKE $${paramCount} OR j.description ILIKE $${paramCount})`);
queryParams.push(`%${search}%`);
}
if (location) {
paramCount++;
conditions.push(`j.location ILIKE $${paramCount}`);
queryParams.push(`%${location}%`);
}
if (employmentType) {
paramCount++;
conditions.push(`j.employment_type = $${paramCount}`);
queryParams.push(employmentType);
}
if (experienceLevel) {
paramCount++;
conditions.push(`j.experience_level = $${paramCount}`);
queryParams.push(experienceLevel);
}
if (skills) {
const skillArray = skills.split(',').map(s => s.trim());
paramCount++;
conditions.push(`j.skills_required && $${paramCount}`);
queryParams.push(skillArray);
}
if (salaryMin) {
paramCount++;
conditions.push(`j.salary_max >= $${paramCount}`);
queryParams.push(parseInt(salaryMin));
}
if (salaryMax) {
paramCount++;
conditions.push(`j.salary_min <= $${paramCount}`);
queryParams.push(parseInt(salaryMax));
}
if (remoteAllowed === 'true') {
conditions.push(`j.remote_allowed = true`);
}
if (conditions.length > 0) {
query += ` WHERE ${conditions.join(' AND ')}`;
}
query += ` ORDER BY j.created_at DESC`;
// Add pagination
const offset = (page - 1) * limit;
paramCount++;
query += ` LIMIT $${paramCount}`;
queryParams.push(limit);
paramCount++;
query += ` OFFSET $${paramCount}`;
queryParams.push(offset);
const result = await pool.query(query, queryParams);
// Get total count for pagination
let countQuery = `
SELECT COUNT(*)
FROM jobs j
JOIN employers e ON j.employer_id = e.id
`;
const countParams = [];
let countParamCount = 0;
const countConditions = [];
countParamCount++;
countConditions.push(`j.status = $${countParamCount}`);
countParams.push(status);
if (search) {
countParamCount++;
countConditions.push(`(j.title ILIKE $${countParamCount} OR j.description ILIKE $${countParamCount})`);
countParams.push(`%${search}%`);
}
if (location) {
countParamCount++;
countConditions.push(`j.location ILIKE $${countParamCount}`);
countParams.push(`%${location}%`);
}
if (employmentType) {
countParamCount++;
countConditions.push(`j.employment_type = $${countParamCount}`);
countParams.push(employmentType);
}
if (experienceLevel) {
countParamCount++;
countConditions.push(`j.experience_level = $${countParamCount}`);
countParams.push(experienceLevel);
}
if (skills) {
const skillArray = skills.split(',').map(s => s.trim());
countParamCount++;
countConditions.push(`j.skills_required && $${countParamCount}`);
countParams.push(skillArray);
}
if (salaryMin) {
countParamCount++;
countConditions.push(`j.salary_max >= $${countParamCount}`);
countParams.push(parseInt(salaryMin));
}
if (salaryMax) {
countParamCount++;
countConditions.push(`j.salary_min <= $${countParamCount}`);
countParams.push(parseInt(salaryMax));
}
if (remoteAllowed === 'true') {
countConditions.push(`j.remote_allowed = true`);
}
if (countConditions.length > 0) {
countQuery += ` WHERE ${countConditions.join(' AND ')}`;
}
const countResult = await pool.query(countQuery, countParams);
res.json({
jobs: result.rows,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: parseInt(countResult.rows[0].count),
pages: Math.ceil(countResult.rows[0].count / limit)
}
});
} catch (error) {
console.error('Get jobs error:', error);
res.status(500).json({ error: 'Failed to fetch jobs' });
}
});
// Get job by ID
router.get('/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(`
SELECT j.*, e.company_name, e.industry, e.company_size, e.website, e.description as company_description
FROM jobs j
JOIN employers e ON j.employer_id = e.id
WHERE j.id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Job not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Get job error:', error);
res.status(500).json({ error: 'Failed to fetch job' });
}
});
// Create job posting
router.post('/', authenticateToken, requireRole(['employer', 'recruiter']), [
body('title').notEmpty().trim(),
body('description').notEmpty().trim(),
body('requirements').isArray(),
body('responsibilities').isArray(),
body('location').notEmpty().trim(),
body('employmentType').isIn(['full-time', 'part-time', 'contract', 'internship']),
body('salaryMin').optional().isInt({ min: 0 }),
body('salaryMax').optional().isInt({ min: 0 }),
body('currency').optional().isLength({ min: 3, max: 3 }),
body('remoteAllowed').optional().isBoolean(),
body('experienceLevel').optional().isIn(['entry', 'mid', 'senior', 'lead', 'executive']),
body('skillsRequired').optional().isArray(),
body('benefits').optional().isArray(),
body('applicationDeadline').optional().isISO8601()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
title,
description,
requirements,
responsibilities,
location,
employmentType,
salaryMin,
salaryMax,
currency = 'USD',
remoteAllowed = false,
experienceLevel,
skillsRequired,
benefits,
applicationDeadline
} = req.body;
// Get employer_id for the current user
let employerId;
if (req.user.role === 'employer') {
const employerResult = await pool.query(
'SELECT id FROM employers WHERE user_id = $1',
[req.user.id]
);
if (employerResult.rows.length === 0) {
return res.status(400).json({ error: 'Employer profile not found' });
}
employerId = employerResult.rows[0].id;
} else {
// For recruiters, they need to specify which employer
const { employerId: providedEmployerId } = req.body;
if (!providedEmployerId) {
return res.status(400).json({ error: 'Employer ID required for recruiters' });
}
employerId = providedEmployerId;
}
const result = await pool.query(`
INSERT INTO jobs (employer_id, title, description, requirements, responsibilities, location, employment_type, salary_min, salary_max, currency, remote_allowed, experience_level, skills_required, benefits, application_deadline)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING *
`, [employerId, title, description, requirements, responsibilities, location, employmentType, salaryMin, salaryMax, currency, remoteAllowed, experienceLevel, skillsRequired, benefits, applicationDeadline]);
res.status(201).json({
message: 'Job posting created successfully',
job: result.rows[0]
});
} catch (error) {
console.error('Create job error:', error);
res.status(500).json({ error: 'Failed to create job posting' });
}
});
// Update job posting
router.put('/:id', authenticateToken, [
body('title').optional().notEmpty().trim(),
body('description').optional().notEmpty().trim(),
body('requirements').optional().isArray(),
body('responsibilities').optional().isArray(),
body('location').optional().notEmpty().trim(),
body('employmentType').optional().isIn(['full-time', 'part-time', 'contract', 'internship']),
body('salaryMin').optional().isInt({ min: 0 }),
body('salaryMax').optional().isInt({ min: 0 }),
body('currency').optional().isLength({ min: 3, max: 3 }),
body('status').optional().isIn(['active', 'paused', 'closed', 'draft']),
body('remoteAllowed').optional().isBoolean(),
body('experienceLevel').optional().isIn(['entry', 'mid', 'senior', 'lead', 'executive']),
body('skillsRequired').optional().isArray(),
body('benefits').optional().isArray(),
body('applicationDeadline').optional().isISO8601()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const {
title,
description,
requirements,
responsibilities,
location,
employmentType,
salaryMin,
salaryMax,
currency,
status,
remoteAllowed,
experienceLevel,
skillsRequired,
benefits,
applicationDeadline
} = req.body;
// Check if job exists and user has permission
const jobResult = await pool.query(`
SELECT j.*, e.user_id as employer_user_id
FROM jobs j
JOIN employers e ON j.employer_id = e.id
WHERE j.id = $1
`, [id]);
if (jobResult.rows.length === 0) {
return res.status(404).json({ error: 'Job not found' });
}
const job = jobResult.rows[0];
// Users can only update jobs from their own employer unless they're admin
if (job.employer_user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied' });
}
const updateFields = [];
const updateValues = [];
let paramCount = 1;
if (title) {
updateFields.push(`title = $${paramCount}`);
updateValues.push(title);
paramCount++;
}
if (description) {
updateFields.push(`description = $${paramCount}`);
updateValues.push(description);
paramCount++;
}
if (requirements) {
updateFields.push(`requirements = $${paramCount}`);
updateValues.push(requirements);
paramCount++;
}
if (responsibilities) {
updateFields.push(`responsibilities = $${paramCount}`);
updateValues.push(responsibilities);
paramCount++;
}
if (location) {
updateFields.push(`location = $${paramCount}`);
updateValues.push(location);
paramCount++;
}
if (employmentType) {
updateFields.push(`employment_type = $${paramCount}`);
updateValues.push(employmentType);
paramCount++;
}
if (salaryMin !== undefined) {
updateFields.push(`salary_min = $${paramCount}`);
updateValues.push(salaryMin);
paramCount++;
}
if (salaryMax !== undefined) {
updateFields.push(`salary_max = $${paramCount}`);
updateValues.push(salaryMax);
paramCount++;
}
if (currency) {
updateFields.push(`currency = $${paramCount}`);
updateValues.push(currency);
paramCount++;
}
if (status) {
updateFields.push(`status = $${paramCount}`);
updateValues.push(status);
paramCount++;
}
if (remoteAllowed !== undefined) {
updateFields.push(`remote_allowed = $${paramCount}`);
updateValues.push(remoteAllowed);
paramCount++;
}
if (experienceLevel) {
updateFields.push(`experience_level = $${paramCount}`);
updateValues.push(experienceLevel);
paramCount++;
}
if (skillsRequired) {
updateFields.push(`skills_required = $${paramCount}`);
updateValues.push(skillsRequired);
paramCount++;
}
if (benefits) {
updateFields.push(`benefits = $${paramCount}`);
updateValues.push(benefits);
paramCount++;
}
if (applicationDeadline) {
updateFields.push(`application_deadline = $${paramCount}`);
updateValues.push(applicationDeadline);
paramCount++;
}
if (updateFields.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
updateValues.push(id);
const query = `UPDATE jobs SET ${updateFields.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = $${paramCount} RETURNING *`;
const result = await pool.query(query, updateValues);
res.json({
message: 'Job posting updated successfully',
job: result.rows[0]
});
} catch (error) {
console.error('Update job error:', error);
res.status(500).json({ error: 'Failed to update job posting' });
}
});
// Delete job posting
router.delete('/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
// Check if job exists and user has permission
const jobResult = await pool.query(`
SELECT j.*, e.user_id as employer_user_id
FROM jobs j
JOIN employers e ON j.employer_id = e.id
WHERE j.id = $1
`, [id]);
if (jobResult.rows.length === 0) {
return res.status(404).json({ error: 'Job not found' });
}
const job = jobResult.rows[0];
// Users can only delete jobs from their own employer unless they're admin
if (job.employer_user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied' });
}
await pool.query('DELETE FROM jobs WHERE id = $1', [id]);
res.json({ message: 'Job posting deleted successfully' });
} catch (error) {
console.error('Delete job error:', error);
res.status(500).json({ error: 'Failed to delete job posting' });
}
});
module.exports = router;

View File

@@ -0,0 +1,298 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const pool = require('../database/connection');
const { authenticateToken, requireRole } = require('../middleware/auth');
const router = express.Router();
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../uploads/resumes');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueName = `${uuidv4()}-${file.originalname}`;
cb(null, uniqueName);
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: 10 * 1024 * 1024 // 10MB limit
},
fileFilter: (req, file, cb) => {
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain'
];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only PDF, DOC, DOCX, and TXT files are allowed.'));
}
}
});
// Get all resumes for a candidate
router.get('/candidate/:candidateId', authenticateToken, async (req, res) => {
try {
const { candidateId } = req.params;
// Check permissions
if (req.user.role === 'candidate') {
const candidateResult = await pool.query(
'SELECT id FROM candidates WHERE user_id = $1',
[req.user.id]
);
if (candidateResult.rows.length === 0 || candidateResult.rows[0].id !== candidateId) {
return res.status(403).json({ error: 'Access denied' });
}
} else if (req.user.role !== 'admin' && req.user.role !== 'recruiter' && req.user.role !== 'employer') {
return res.status(403).json({ error: 'Access denied' });
}
const result = await pool.query(
'SELECT * FROM resumes WHERE candidate_id = $1 ORDER BY uploaded_at DESC',
[candidateId]
);
res.json(result.rows);
} catch (error) {
console.error('Get resumes error:', error);
res.status(500).json({ error: 'Failed to fetch resumes' });
}
});
// Get resume by ID
router.get('/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
'SELECT * FROM resumes WHERE id = $1',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Resume not found' });
}
const resume = result.rows[0];
// Check permissions
if (req.user.role === 'candidate') {
const candidateResult = await pool.query(
'SELECT id FROM candidates WHERE user_id = $1',
[req.user.id]
);
if (candidateResult.rows.length === 0 || candidateResult.rows[0].id !== resume.candidate_id) {
return res.status(403).json({ error: 'Access denied' });
}
} else if (req.user.role !== 'admin' && req.user.role !== 'recruiter' && req.user.role !== 'employer') {
return res.status(403).json({ error: 'Access denied' });
}
res.json(resume);
} catch (error) {
console.error('Get resume error:', error);
res.status(500).json({ error: 'Failed to fetch resume' });
}
});
// Upload resume
router.post('/upload', authenticateToken, requireRole(['candidate']), upload.single('resume'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// Get candidate ID for the current user
const candidateResult = await pool.query(
'SELECT id FROM candidates WHERE user_id = $1',
[req.user.id]
);
if (candidateResult.rows.length === 0) {
return res.status(400).json({ error: 'Candidate profile not found' });
}
const candidateId = candidateResult.rows[0].id;
// If this is set as primary, unset other primary resumes
if (req.body.isPrimary === 'true') {
await pool.query(
'UPDATE resumes SET is_primary = false WHERE candidate_id = $1',
[candidateId]
);
}
const result = await pool.query(`
INSERT INTO resumes (candidate_id, filename, original_name, file_path, file_size, mime_type, is_primary)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`, [
candidateId,
req.file.filename,
req.file.originalname,
req.file.path,
req.file.size,
req.file.mimetype,
req.body.isPrimary === 'true'
]);
res.status(201).json({
message: 'Resume uploaded successfully',
resume: result.rows[0]
});
} catch (error) {
console.error('Upload resume error:', error);
res.status(500).json({ error: 'Failed to upload resume' });
}
});
// Download resume
router.get('/:id/download', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
'SELECT * FROM resumes WHERE id = $1',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Resume not found' });
}
const resume = result.rows[0];
// Check permissions
if (req.user.role === 'candidate') {
const candidateResult = await pool.query(
'SELECT id FROM candidates WHERE user_id = $1',
[req.user.id]
);
if (candidateResult.rows.length === 0 || candidateResult.rows[0].id !== resume.candidate_id) {
return res.status(403).json({ error: 'Access denied' });
}
} else if (req.user.role !== 'admin' && req.user.role !== 'recruiter' && req.user.role !== 'employer') {
return res.status(403).json({ error: 'Access denied' });
}
// Check if file exists
if (!fs.existsSync(resume.file_path)) {
return res.status(404).json({ error: 'Resume file not found' });
}
res.download(resume.file_path, resume.original_name);
} catch (error) {
console.error('Download resume error:', error);
res.status(500).json({ error: 'Failed to download resume' });
}
});
// Set primary resume
router.put('/:id/primary', authenticateToken, requireRole(['candidate']), async (req, res) => {
try {
const { id } = req.params;
// Get candidate ID for the current user
const candidateResult = await pool.query(
'SELECT id FROM candidates WHERE user_id = $1',
[req.user.id]
);
if (candidateResult.rows.length === 0) {
return res.status(400).json({ error: 'Candidate profile not found' });
}
const candidateId = candidateResult.rows[0].id;
// Check if resume exists and belongs to the candidate
const resumeResult = await pool.query(
'SELECT id FROM resumes WHERE id = $1 AND candidate_id = $2',
[id, candidateId]
);
if (resumeResult.rows.length === 0) {
return res.status(404).json({ error: 'Resume not found' });
}
// Unset other primary resumes
await pool.query(
'UPDATE resumes SET is_primary = false WHERE candidate_id = $1',
[candidateId]
);
// Set this resume as primary
const result = await pool.query(
'UPDATE resumes SET is_primary = true WHERE id = $1 RETURNING *',
[id]
);
res.json({
message: 'Primary resume updated successfully',
resume: result.rows[0]
});
} catch (error) {
console.error('Set primary resume error:', error);
res.status(500).json({ error: 'Failed to set primary resume' });
}
});
// Delete resume
router.delete('/:id', authenticateToken, requireRole(['candidate']), async (req, res) => {
try {
const { id } = req.params;
// Get candidate ID for the current user
const candidateResult = await pool.query(
'SELECT id FROM candidates WHERE user_id = $1',
[req.user.id]
);
if (candidateResult.rows.length === 0) {
return res.status(400).json({ error: 'Candidate profile not found' });
}
const candidateId = candidateResult.rows[0].id;
// Check if resume exists and belongs to the candidate
const resumeResult = await pool.query(
'SELECT * FROM resumes WHERE id = $1 AND candidate_id = $2',
[id, candidateId]
);
if (resumeResult.rows.length === 0) {
return res.status(404).json({ error: 'Resume not found' });
}
const resume = resumeResult.rows[0];
// Delete file from filesystem
if (fs.existsSync(resume.file_path)) {
fs.unlinkSync(resume.file_path);
}
// Delete from database
await pool.query('DELETE FROM resumes WHERE id = $1', [id]);
res.json({ message: 'Resume deleted successfully' });
} catch (error) {
console.error('Delete resume error:', error);
res.status(500).json({ error: 'Failed to delete resume' });
}
});
module.exports = router;

165
backend/src/routes/users.js Normal file
View File

@@ -0,0 +1,165 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const pool = require('../database/connection');
const { authenticateToken, requireRole } = require('../middleware/auth');
const router = express.Router();
// Get all users (admin only)
router.get('/', authenticateToken, requireRole(['admin']), async (req, res) => {
try {
const result = await pool.query(
'SELECT id, email, first_name, last_name, role, is_active, created_at FROM users ORDER BY created_at DESC'
);
res.json(result.rows);
} catch (error) {
console.error('Get users error:', error);
res.status(500).json({ error: 'Failed to fetch users' });
}
});
// Get user by ID
router.get('/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
// Users can only view their own profile unless they're admin
if (req.user.id !== id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied' });
}
const result = await pool.query(
'SELECT id, email, first_name, last_name, role, is_active, created_at FROM users WHERE id = $1',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Get user error:', error);
res.status(500).json({ error: 'Failed to fetch user' });
}
});
// Update user profile
router.put('/:id', authenticateToken, [
body('firstName').optional().notEmpty().trim(),
body('lastName').optional().notEmpty().trim(),
body('email').optional().isEmail().normalizeEmail()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { firstName, lastName, email } = req.body;
// Users can only update their own profile unless they're admin
if (req.user.id !== id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Access denied' });
}
// Check if email is already taken by another user
if (email) {
const existingUser = await pool.query(
'SELECT id FROM users WHERE email = $1 AND id != $2',
[email, id]
);
if (existingUser.rows.length > 0) {
return res.status(400).json({ error: 'Email already in use' });
}
}
const updateFields = [];
const updateValues = [];
let paramCount = 1;
if (firstName) {
updateFields.push(`first_name = $${paramCount}`);
updateValues.push(firstName);
paramCount++;
}
if (lastName) {
updateFields.push(`last_name = $${paramCount}`);
updateValues.push(lastName);
paramCount++;
}
if (email) {
updateFields.push(`email = $${paramCount}`);
updateValues.push(email);
paramCount++;
}
if (updateFields.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
updateValues.push(id);
const query = `UPDATE users SET ${updateFields.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = $${paramCount} RETURNING id, email, first_name, last_name, role, is_active, updated_at`;
const result = await pool.query(query, updateValues);
res.json({
message: 'User updated successfully',
user: result.rows[0]
});
} catch (error) {
console.error('Update user error:', error);
res.status(500).json({ error: 'Failed to update user' });
}
});
// Deactivate user (admin only)
router.put('/:id/deactivate', authenticateToken, requireRole(['admin']), async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
'UPDATE users SET is_active = false, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, email, first_name, last_name, role, is_active',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json({
message: 'User deactivated successfully',
user: result.rows[0]
});
} catch (error) {
console.error('Deactivate user error:', error);
res.status(500).json({ error: 'Failed to deactivate user' });
}
});
// Activate user (admin only)
router.put('/:id/activate', authenticateToken, requireRole(['admin']), async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
'UPDATE users SET is_active = true, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, email, first_name, last_name, role, is_active',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json({
message: 'User activated successfully',
user: result.rows[0]
});
} catch (error) {
console.error('Activate user error:', error);
res.status(500).json({ error: 'Failed to activate user' });
}
});
module.exports = router;

57
backend/src/server.js Normal file
View File

@@ -0,0 +1,57 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
require('dotenv').config();
const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
const employerRoutes = require('./routes/employers');
const candidateRoutes = require('./routes/candidates');
const jobRoutes = require('./routes/jobs');
const applicationRoutes = require('./routes/applications');
const resumeRoutes = require('./routes/resumes');
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(helmet());
app.use(cors());
app.use(morgan('combined'));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/employers', employerRoutes);
app.use('/api/candidates', candidateRoutes);
app.use('/api/jobs', jobRoutes);
app.use('/api/applications', applicationRoutes);
app.use('/api/resumes', resumeRoutes);
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'OK', timestamp: new Date().toISOString() });
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: 'Something went wrong!',
message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error'
});
});
// 404 handler
app.use('*', (req, res) => {
res.status(404).json({ error: 'Route not found' });
});
app.listen(PORT, () => {
console.log(`MysteryApp-Cursor backend server running on port ${PORT}`);
});
module.exports = app;

View File

@@ -0,0 +1,185 @@
const request = require('supertest');
const app = require('../server');
const pool = require('../database/connection');
describe('Authentication', () => {
beforeEach(async () => {
// Clean up database before each test
await pool.query('DELETE FROM users WHERE email LIKE $1', ['test%']);
});
afterAll(async () => {
await pool.end();
});
describe('POST /api/auth/register', () => {
it('should register a new user successfully', async () => {
const userData = {
firstName: 'Test',
lastName: 'User',
email: 'test@example.com',
password: 'password123',
role: 'candidate'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(201);
expect(response.body).toHaveProperty('message', 'User created successfully');
expect(response.body).toHaveProperty('token');
expect(response.body).toHaveProperty('user');
expect(response.body.user.email).toBe(userData.email);
});
it('should return error for duplicate email', async () => {
const userData = {
firstName: 'Test',
lastName: 'User',
email: 'test@example.com',
password: 'password123',
role: 'candidate'
};
// Register first user
await request(app)
.post('/api/auth/register')
.send(userData);
// Try to register with same email
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(400);
expect(response.body).toHaveProperty('error', 'User already exists');
});
it('should return error for invalid role', async () => {
const userData = {
firstName: 'Test',
lastName: 'User',
email: 'test@example.com',
password: 'password123',
role: 'invalid_role'
};
const response = await request(app)
.post('/api/auth/register')
.send(userData)
.expect(400);
expect(response.body).toHaveProperty('errors');
});
});
describe('POST /api/auth/login', () => {
beforeEach(async () => {
// Create a test user
const userData = {
firstName: 'Test',
lastName: 'User',
email: 'test@example.com',
password: 'password123',
role: 'candidate'
};
await request(app)
.post('/api/auth/register')
.send(userData);
});
it('should login with valid credentials', async () => {
const loginData = {
email: 'test@example.com',
password: 'password123'
};
const response = await request(app)
.post('/api/auth/login')
.send(loginData)
.expect(200);
expect(response.body).toHaveProperty('message', 'Login successful');
expect(response.body).toHaveProperty('token');
expect(response.body).toHaveProperty('user');
});
it('should return error for invalid credentials', async () => {
const loginData = {
email: 'test@example.com',
password: 'wrongpassword'
};
const response = await request(app)
.post('/api/auth/login')
.send(loginData)
.expect(401);
expect(response.body).toHaveProperty('error', 'Invalid credentials');
});
it('should return error for non-existent user', async () => {
const loginData = {
email: 'nonexistent@example.com',
password: 'password123'
};
const response = await request(app)
.post('/api/auth/login')
.send(loginData)
.expect(401);
expect(response.body).toHaveProperty('error', 'Invalid credentials');
});
});
describe('GET /api/auth/me', () => {
let token;
beforeEach(async () => {
// Create a test user and get token
const userData = {
firstName: 'Test',
lastName: 'User',
email: 'test@example.com',
password: 'password123',
role: 'candidate'
};
const registerResponse = await request(app)
.post('/api/auth/register')
.send(userData);
token = registerResponse.body.token;
});
it('should return user data with valid token', async () => {
const response = await request(app)
.get('/api/auth/me')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(response.body).toHaveProperty('user');
expect(response.body.user.email).toBe('test@example.com');
});
it('should return error without token', async () => {
const response = await request(app)
.get('/api/auth/me')
.expect(401);
expect(response.body).toHaveProperty('error', 'Access token required');
});
it('should return error with invalid token', async () => {
const response = await request(app)
.get('/api/auth/me')
.set('Authorization', 'Bearer invalid_token')
.expect(403);
expect(response.body).toHaveProperty('error', 'Invalid or expired token');
});
});
});

View File

@@ -0,0 +1,252 @@
const request = require('supertest');
const app = require('../server');
const pool = require('../database/connection');
describe('Jobs API', () => {
let authToken;
let employerId;
beforeAll(async () => {
// Create test user and get auth token
const userData = {
firstName: 'Test',
lastName: 'Employer',
email: 'employer@test.com',
password: 'password123',
role: 'employer'
};
const registerResponse = await request(app)
.post('/api/auth/register')
.send(userData);
authToken = registerResponse.body.token;
// Create employer profile
const employerData = {
companyName: 'Test Company',
industry: 'Technology',
companySize: '50-200',
website: 'https://testcompany.com',
description: 'A test company',
address: '123 Test St',
phone: '+1-555-0123'
};
const employerResponse = await request(app)
.post('/api/employers')
.set('Authorization', `Bearer ${authToken}`)
.send(employerData);
employerId = employerResponse.body.employer.id;
});
afterAll(async () => {
await pool.end();
});
describe('POST /api/jobs', () => {
it('should create a new job posting', async () => {
const jobData = {
title: 'Senior Developer',
description: 'We are looking for a senior developer',
requirements: ['5+ years experience', 'JavaScript knowledge'],
responsibilities: ['Develop applications', 'Code reviews'],
location: 'San Francisco, CA',
employmentType: 'full-time',
salaryMin: 100000,
salaryMax: 150000,
remoteAllowed: true,
experienceLevel: 'senior',
skillsRequired: ['JavaScript', 'React', 'Node.js'],
benefits: ['Health insurance', '401k']
};
const response = await request(app)
.post('/api/jobs')
.set('Authorization', `Bearer ${authToken}`)
.send(jobData)
.expect(201);
expect(response.body).toHaveProperty('message', 'Job posting created successfully');
expect(response.body).toHaveProperty('job');
expect(response.body.job.title).toBe(jobData.title);
});
it('should return error for missing required fields', async () => {
const jobData = {
title: 'Senior Developer'
// Missing required fields
};
const response = await request(app)
.post('/api/jobs')
.set('Authorization', `Bearer ${authToken}`)
.send(jobData)
.expect(400);
expect(response.body).toHaveProperty('errors');
});
});
describe('GET /api/jobs', () => {
beforeEach(async () => {
// Create a test job
const jobData = {
title: 'Test Job',
description: 'A test job description',
requirements: ['Test requirement'],
responsibilities: ['Test responsibility'],
location: 'Test Location',
employmentType: 'full-time'
};
await request(app)
.post('/api/jobs')
.set('Authorization', `Bearer ${authToken}`)
.send(jobData);
});
it('should return list of jobs', async () => {
const response = await request(app)
.get('/api/jobs')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body).toHaveProperty('jobs');
expect(response.body).toHaveProperty('pagination');
expect(Array.isArray(response.body.jobs)).toBe(true);
});
it('should filter jobs by search term', async () => {
const response = await request(app)
.get('/api/jobs?search=Test')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.jobs.length).toBeGreaterThan(0);
});
it('should filter jobs by location', async () => {
const response = await request(app)
.get('/api/jobs?location=Test')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.jobs.length).toBeGreaterThan(0);
});
});
describe('GET /api/jobs/:id', () => {
let jobId;
beforeEach(async () => {
// Create a test job
const jobData = {
title: 'Test Job for Details',
description: 'A test job description',
requirements: ['Test requirement'],
responsibilities: ['Test responsibility'],
location: 'Test Location',
employmentType: 'full-time'
};
const response = await request(app)
.post('/api/jobs')
.set('Authorization', `Bearer ${authToken}`)
.send(jobData);
jobId = response.body.job.id;
});
it('should return job details', async () => {
const response = await request(app)
.get(`/api/jobs/${jobId}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body).toHaveProperty('id', jobId);
expect(response.body).toHaveProperty('title', 'Test Job for Details');
});
it('should return 404 for non-existent job', async () => {
const response = await request(app)
.get('/api/jobs/00000000-0000-0000-0000-000000000000')
.set('Authorization', `Bearer ${authToken}`)
.expect(404);
expect(response.body).toHaveProperty('error', 'Job not found');
});
});
describe('PUT /api/jobs/:id', () => {
let jobId;
beforeEach(async () => {
// Create a test job
const jobData = {
title: 'Test Job for Update',
description: 'A test job description',
requirements: ['Test requirement'],
responsibilities: ['Test responsibility'],
location: 'Test Location',
employmentType: 'full-time'
};
const response = await request(app)
.post('/api/jobs')
.set('Authorization', `Bearer ${authToken}`)
.send(jobData);
jobId = response.body.job.id;
});
it('should update job successfully', async () => {
const updateData = {
title: 'Updated Test Job',
description: 'Updated description'
};
const response = await request(app)
.put(`/api/jobs/${jobId}`)
.set('Authorization', `Bearer ${authToken}`)
.send(updateData)
.expect(200);
expect(response.body).toHaveProperty('message', 'Job posting updated successfully');
expect(response.body.job.title).toBe('Updated Test Job');
});
});
describe('DELETE /api/jobs/:id', () => {
let jobId;
beforeEach(async () => {
// Create a test job
const jobData = {
title: 'Test Job for Delete',
description: 'A test job description',
requirements: ['Test requirement'],
responsibilities: ['Test responsibility'],
location: 'Test Location',
employmentType: 'full-time'
};
const response = await request(app)
.post('/api/jobs')
.set('Authorization', `Bearer ${authToken}`)
.send(jobData);
jobId = response.body.job.id;
});
it('should delete job successfully', async () => {
const response = await request(app)
.delete(`/api/jobs/${jobId}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body).toHaveProperty('message', 'Job posting deleted successfully');
});
});
});

58
docker-compose.yml Normal file
View File

@@ -0,0 +1,58 @@
services:
mysteryapp-cursor-database:
image: postgres:15-alpine
container_name: mysteryapp-cursor-database
environment:
POSTGRES_DB: mysteryapp_cursor
POSTGRES_USER: mysteryapp_user
POSTGRES_PASSWORD: mysteryapp_password
ports:
- "0.0.0.0:5432:5432"
volumes:
- mysteryapp-cursor-postgres-data:/var/lib/postgresql/data
networks:
- mysteryapp-cursor-network
mysteryapp-cursor-backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: mysteryapp-cursor-backend
environment:
NODE_ENV: development
DATABASE_URL: postgresql://mysteryapp_user:mysteryapp_password@mysteryapp-cursor-database:5432/mysteryapp_cursor
JWT_SECRET: mysteryapp_jwt_secret_key_2024
PORT: 3001
ports:
- "0.0.0.0:3001:3001"
depends_on:
- mysteryapp-cursor-database
volumes:
- ./backend:/app
- /app/node_modules
networks:
- mysteryapp-cursor-network
mysteryapp-cursor-frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: mysteryapp-cursor-frontend
environment:
REACT_APP_API_URL: http://localhost:3001
ports:
- "0.0.0.0:12000:3000"
depends_on:
- mysteryapp-cursor-backend
volumes:
- ./frontend:/app
- /app/node_modules
networks:
- mysteryapp-cursor-network
volumes:
mysteryapp-cursor-postgres-data:
networks:
mysteryapp-cursor-network:
driver: bridge

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]

53
frontend/package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "mysteryapp-cursor-frontend",
"version": "1.0.0",
"description": "Frontend for MysteryApp-Cursor recruiter workflow SAAS",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.5.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.1",
"react-scripts": "5.0.1",
"axios": "^1.6.2",
"react-hook-form": "^7.48.2",
"react-query": "^3.39.3",
"react-hot-toast": "^2.4.1",
"lucide-react": "^0.294.0",
"clsx": "^2.0.0",
"tailwindcss": "^3.3.6",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17"
},
"proxy": "http://MysteryApp-Cursor-backend:3001"
}

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="MysteryApp-Cursor - Professional Recruiter Workflow SAAS"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>MysteryApp-Cursor</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

136
frontend/src/App.js Normal file
View File

@@ -0,0 +1,136 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Toaster } from 'react-hot-toast';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import Layout from './components/Layout';
import Login from './pages/Login';
import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
import Jobs from './pages/Jobs';
import JobDetails from './pages/JobDetails';
import CreateJob from './pages/CreateJob';
import Candidates from './pages/Candidates';
import CandidateDetails from './pages/CandidateDetails';
import Applications from './pages/Applications';
import Profile from './pages/Profile';
import Employers from './pages/Employers';
import EmployerDetails from './pages/EmployerDetails';
import Resumes from './pages/Resumes';
const queryClient = new QueryClient();
function ProtectedRoute({ children, allowedRoles = [] }) {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
if (allowedRoles.length > 0 && !allowedRoles.includes(user.role)) {
return <Navigate to="/dashboard" replace />;
}
return children;
}
function AppRoutes() {
const { user } = useAuth();
return (
<Routes>
<Route path="/login" element={!user ? <Login /> : <Navigate to="/dashboard" replace />} />
<Route path="/register" element={!user ? <Register /> : <Navigate to="/dashboard" replace />} />
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
} />
<Route path="jobs" element={
<ProtectedRoute>
<Jobs />
</ProtectedRoute>
} />
<Route path="jobs/create" element={
<ProtectedRoute allowedRoles={['employer', 'recruiter']}>
<CreateJob />
</ProtectedRoute>
} />
<Route path="jobs/:id" element={
<ProtectedRoute>
<JobDetails />
</ProtectedRoute>
} />
<Route path="candidates" element={
<ProtectedRoute allowedRoles={['admin', 'recruiter', 'employer']}>
<Candidates />
</ProtectedRoute>
} />
<Route path="candidates/:id" element={
<ProtectedRoute allowedRoles={['admin', 'recruiter', 'employer']}>
<CandidateDetails />
</ProtectedRoute>
} />
<Route path="applications" element={
<ProtectedRoute>
<Applications />
</ProtectedRoute>
} />
<Route path="employers" element={
<ProtectedRoute allowedRoles={['admin', 'recruiter']}>
<Employers />
</ProtectedRoute>
} />
<Route path="employers/:id" element={
<ProtectedRoute allowedRoles={['admin', 'recruiter']}>
<EmployerDetails />
</ProtectedRoute>
} />
<Route path="resumes" element={
<ProtectedRoute allowedRoles={['candidate']}>
<Resumes />
</ProtectedRoute>
} />
<Route path="profile" element={
<ProtectedRoute>
<Profile />
</ProtectedRoute>
} />
</Route>
</Routes>
);
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Router>
<div className="App">
<AppRoutes />
<Toaster position="top-right" />
</div>
</Router>
</AuthProvider>
</QueryClientProvider>
);
}
export default App;

43
frontend/src/App.test.js Normal file
View File

@@ -0,0 +1,43 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
// Mock the AuthContext
jest.mock('./contexts/AuthContext', () => ({
useAuth: () => ({
user: null,
loading: false,
login: jest.fn(),
register: jest.fn(),
logout: jest.fn(),
fetchUser: jest.fn()
}),
AuthProvider: ({ children }) => children
}));
// Mock react-router-dom
jest.mock('react-router-dom', () => ({
BrowserRouter: ({ children }) => <div>{children}</div>,
Routes: ({ children }) => <div>{children}</div>,
Route: ({ children }) => <div>{children}</div>,
Navigate: ({ to }) => <div data-testid="navigate">{to}</div>,
Outlet: () => <div data-testid="outlet">Outlet</div>
}));
// Mock react-query
jest.mock('react-query', () => ({
QueryClient: jest.fn(() => ({})),
QueryClientProvider: ({ children }) => <div>{children}</div>
}));
// Mock react-hot-toast
jest.mock('react-hot-toast', () => ({
Toaster: () => <div data-testid="toaster">Toaster</div>
}));
describe('App', () => {
it('renders without crashing', () => {
render(<App />);
expect(screen.getByTestId('toaster')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,186 @@
import React, { useState } from 'react';
import { Link, useLocation, Outlet } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import {
Home,
Briefcase,
Users,
FileText,
Building,
User,
LogOut,
Menu,
X,
Bell
} from 'lucide-react';
const Layout = () => {
const { user, logout } = useAuth();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: Home, roles: ['admin', 'recruiter', 'employer', 'candidate'] },
{ name: 'Jobs', href: '/jobs', icon: Briefcase, roles: ['admin', 'recruiter', 'employer', 'candidate'] },
{ name: 'Candidates', href: '/candidates', icon: Users, roles: ['admin', 'recruiter', 'employer'] },
{ name: 'Applications', href: '/applications', icon: FileText, roles: ['admin', 'recruiter', 'employer', 'candidate'] },
{ name: 'Employers', href: '/employers', icon: Building, roles: ['admin', 'recruiter'] },
{ name: 'Resumes', href: '/resumes', icon: FileText, roles: ['candidate'] },
];
const filteredNavigation = navigation.filter(item =>
item.roles.includes(user?.role)
);
const handleLogout = () => {
logout();
};
return (
<div className="min-h-screen bg-gray-50">
{/* Mobile sidebar */}
<div className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? 'block' : 'hidden'}`}>
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setSidebarOpen(false)} />
<div className="relative flex-1 flex flex-col max-w-xs w-full bg-white">
<div className="absolute top-0 right-0 -mr-12 pt-2">
<button
type="button"
className="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
onClick={() => setSidebarOpen(false)}
>
<X className="h-6 w-6 text-white" />
</button>
</div>
<div className="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
<div className="flex-shrink-0 flex items-center px-4">
<h1 className="text-xl font-bold text-gray-900">MysteryApp-Cursor</h1>
</div>
<nav className="mt-5 px-2 space-y-1">
{filteredNavigation.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={`${
isActive
? 'bg-primary-100 text-primary-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
} group flex items-center px-2 py-2 text-base font-medium rounded-md`}
>
<item.icon className="mr-4 h-6 w-6" />
{item.name}
</Link>
);
})}
</nav>
</div>
</div>
</div>
{/* Desktop sidebar */}
<div className="hidden lg:flex lg:w-64 lg:flex-col lg:fixed lg:inset-y-0">
<div className="flex-1 flex flex-col min-h-0 border-r border-gray-200 bg-white">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<div className="flex items-center flex-shrink-0 px-4">
<h1 className="text-xl font-bold text-gray-900">MysteryApp-Cursor</h1>
</div>
<nav className="mt-5 flex-1 px-2 space-y-1">
{filteredNavigation.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={`${
isActive
? 'bg-primary-100 text-primary-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
} group flex items-center px-2 py-2 text-sm font-medium rounded-md`}
>
<item.icon className="mr-3 h-6 w-6" />
{item.name}
</Link>
);
})}
</nav>
</div>
<div className="flex-shrink-0 flex border-t border-gray-200 p-4">
<div className="flex-shrink-0 w-full group block">
<div className="flex items-center">
<div className="ml-3">
<p className="text-sm font-medium text-gray-700 group-hover:text-gray-900">
{user?.firstName} {user?.lastName}
</p>
<p className="text-xs font-medium text-gray-500 group-hover:text-gray-700">
{user?.role}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Main content */}
<div className="lg:pl-64 flex flex-col flex-1">
<div className="sticky top-0 z-10 lg:hidden pl-1 pt-1 sm:pl-3 sm:pt-3 bg-gray-100">
<button
type="button"
className="-ml-0.5 -mt-0.5 h-12 w-12 inline-flex items-center justify-center rounded-md text-gray-500 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
onClick={() => setSidebarOpen(true)}
>
<Menu className="h-6 w-6" />
</button>
</div>
<main className="flex-1">
<div className="py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Outlet />
</div>
</div>
</main>
</div>
{/* Top bar for desktop */}
<div className="hidden lg:block lg:pl-64">
<div className="sticky top-0 z-10 flex-shrink-0 flex h-16 bg-white border-b border-gray-200">
<div className="flex-1 px-4 flex justify-between">
<div className="flex-1 flex">
<div className="w-full flex md:ml-0">
<div className="relative w-full text-gray-400 focus-within:text-gray-600">
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none">
<Bell className="h-5 w-5" />
</div>
</div>
</div>
</div>
<div className="ml-4 flex items-center md:ml-6">
<div className="ml-3 relative">
<div className="flex items-center space-x-4">
<Link
to="/profile"
className="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900"
>
<User className="h-5 w-5 mr-2" />
Profile
</Link>
<button
onClick={handleLogout}
className="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900"
>
<LogOut className="h-5 w-5 mr-2" />
Logout
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Layout from './Layout';
// Mock the AuthContext
const mockUseAuth = {
user: {
id: '1',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
role: 'candidate'
},
logout: jest.fn()
};
jest.mock('../contexts/AuthContext', () => ({
useAuth: () => mockUseAuth
}));
// Mock react-router-dom
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({ pathname: '/dashboard' }),
Link: ({ children, to }) => <a href={to}>{children}</a>,
Outlet: () => <div data-testid="outlet">Outlet</div>
}));
const renderWithRouter = (component) => {
return render(
<BrowserRouter>
{component}
</BrowserRouter>
);
};
describe('Layout', () => {
it('renders the layout with user information', () => {
renderWithRouter(<Layout />);
expect(screen.getByText('MysteryApp-Cursor')).toBeInTheDocument();
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('candidate')).toBeInTheDocument();
});
it('renders navigation items for candidate role', () => {
renderWithRouter(<Layout />);
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.getByText('Jobs')).toBeInTheDocument();
expect(screen.getByText('Applications')).toBeInTheDocument();
expect(screen.getByText('Resumes')).toBeInTheDocument();
});
it('renders logout button', () => {
renderWithRouter(<Layout />);
expect(screen.getByText('Logout')).toBeInTheDocument();
});
it('calls logout when logout button is clicked', () => {
renderWithRouter(<Layout />);
const logoutButton = screen.getByText('Logout');
logoutButton.click();
expect(mockUseAuth.logout).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,105 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import axios from 'axios';
import toast from 'react-hot-toast';
const AuthContext = createContext();
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
fetchUser();
} else {
setLoading(false);
}
}, []);
const fetchUser = async () => {
try {
const response = await axios.get('/api/auth/me');
setUser(response.data.user);
} catch (error) {
console.error('Failed to fetch user:', error);
localStorage.removeItem('token');
delete axios.defaults.headers.common['Authorization'];
} finally {
setLoading(false);
}
};
const login = async (email, password) => {
try {
const response = await axios.post('/api/auth/login', { email, password });
const { token, user } = response.data;
localStorage.setItem('token', token);
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
setUser(user);
toast.success('Login successful!');
return { success: true };
} catch (error) {
const message = error.response?.data?.error || 'Login failed';
toast.error(message);
return { success: false, error: message };
}
};
const register = async (userData) => {
try {
const response = await axios.post('/api/auth/register', userData);
const { token, user } = response.data;
localStorage.setItem('token', token);
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
setUser(user);
toast.success('Registration successful!');
return { success: true };
} catch (error) {
const message = error.response?.data?.error || 'Registration failed';
toast.error(message);
return { success: false, error: message };
}
};
const logout = async () => {
try {
await axios.post('/api/auth/logout');
} catch (error) {
console.error('Logout error:', error);
} finally {
localStorage.removeItem('token');
delete axios.defaults.headers.common['Authorization'];
setUser(null);
toast.success('Logged out successfully');
}
};
const value = {
user,
loading,
login,
register,
logout,
fetchUser
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};

47
frontend/src/index.css Normal file
View File

@@ -0,0 +1,47 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: 'Inter', system-ui, sans-serif;
}
}
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
}
.btn-primary {
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
}
.btn-secondary {
@apply bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500;
}
.btn-danger {
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
}
.input {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
.card {
@apply bg-white rounded-lg shadow-md border border-gray-200;
}
.card-header {
@apply px-6 py-4 border-b border-gray-200;
}
.card-body {
@apply px-6 py-4;
}
.card-footer {
@apply px-6 py-4 border-t border-gray-200;
}
}

11
frontend/src/index.js Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { useQuery } from 'react-query';
import axios from 'axios';
import { FileText, Briefcase, Building, Clock, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
const Applications = () => {
const { data, isLoading } = useQuery('applications', async () => {
const response = await axios.get('/api/applications');
return response.data;
});
const getStatusIcon = (status) => {
switch (status) {
case 'applied': return <Clock className="h-4 w-4" />;
case 'reviewed': return <AlertCircle className="h-4 w-4" />;
case 'shortlisted': return <CheckCircle className="h-4 w-4" />;
case 'interviewed': return <CheckCircle className="h-4 w-4" />;
case 'offered': return <CheckCircle className="h-4 w-4" />;
case 'rejected': return <XCircle className="h-4 w-4" />;
default: return <Clock className="h-4 w-4" />;
}
};
const getStatusColor = (status) => {
switch (status) {
case 'applied': return 'bg-blue-100 text-blue-800';
case 'reviewed': return 'bg-yellow-100 text-yellow-800';
case 'shortlisted': return 'bg-green-100 text-green-800';
case 'interviewed': return 'bg-green-100 text-green-800';
case 'offered': return 'bg-green-100 text-green-800';
case 'rejected': return 'bg-red-100 text-red-800';
case 'withdrawn': return 'bg-gray-100 text-gray-800';
default: return 'bg-gray-100 text-gray-800';
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Applications</h1>
<p className="mt-1 text-sm text-gray-500">
Track your job applications and their status
</p>
</div>
<div className="space-y-4">
{data?.applications?.length > 0 ? (
data.applications.map((application) => (
<div key={application.id} className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center">
<h3 className="text-lg font-medium text-gray-900">
{application.job_title}
</h3>
<span className={`ml-3 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(application.status)}`}>
{getStatusIcon(application.status)}
<span className="ml-1 capitalize">{application.status}</span>
</span>
</div>
<div className="mt-1 flex items-center text-sm text-gray-500">
<Building className="h-4 w-4 mr-1" />
{application.company_name}
</div>
<div className="mt-2 flex items-center text-sm text-gray-500">
<Clock className="h-4 w-4 mr-1" />
Applied on {new Date(application.applied_at).toLocaleDateString()}
</div>
{application.cover_letter && (
<div className="mt-3">
<p className="text-sm text-gray-600 line-clamp-2">
{application.cover_letter}
</p>
</div>
)}
{application.notes && (
<div className="mt-3">
<p className="text-sm text-gray-600">
<strong>Notes:</strong> {application.notes}
</p>
</div>
)}
</div>
</div>
</div>
</div>
))
) : (
<div className="text-center py-12">
<FileText className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No applications found</h3>
<p className="mt-1 text-sm text-gray-500">
You haven't applied to any jobs yet.
</p>
</div>
)}
</div>
</div>
);
};
export default Applications;

View File

@@ -0,0 +1,149 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from 'react-query';
import axios from 'axios';
import { MapPin, Mail, Phone, Linkedin, Github, Globe, User } from 'lucide-react';
const CandidateDetails = () => {
const { id } = useParams();
const { data: candidate, isLoading } = useQuery(['candidate', id], async () => {
const response = await axios.get(`/api/candidates/${id}`);
return response.data;
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
</div>
);
}
if (!candidate) {
return (
<div className="text-center py-12">
<h3 className="text-lg font-medium text-gray-900">Candidate not found</h3>
<p className="mt-1 text-sm text-gray-500">
The candidate you're looking for doesn't exist.
</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="h-16 w-16 rounded-full bg-primary-100 flex items-center justify-center">
<User className="h-8 w-8 text-primary-600" />
</div>
</div>
<div className="ml-6 flex-1">
<h1 className="text-2xl font-bold text-gray-900">
{candidate.first_name} {candidate.last_name}
</h1>
<p className="text-lg text-gray-600">{candidate.email}</p>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
{candidate.location && (
<div className="flex items-center text-sm text-gray-500">
<MapPin className="h-4 w-4 mr-2" />
{candidate.location}
</div>
)}
{candidate.phone && (
<div className="flex items-center text-sm text-gray-500">
<Phone className="h-4 w-4 mr-2" />
{candidate.phone}
</div>
)}
{candidate.linkedin_url && (
<div className="flex items-center text-sm text-gray-500">
<Linkedin className="h-4 w-4 mr-2" />
<a href={candidate.linkedin_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500">
LinkedIn Profile
</a>
</div>
)}
{candidate.github_url && (
<div className="flex items-center text-sm text-gray-500">
<Github className="h-4 w-4 mr-2" />
<a href={candidate.github_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500">
GitHub Profile
</a>
</div>
)}
{candidate.portfolio_url && (
<div className="flex items-center text-sm text-gray-500">
<Globe className="h-4 w-4 mr-2" />
<a href={candidate.portfolio_url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500">
Portfolio
</a>
</div>
)}
</div>
</div>
</div>
</div>
</div>
{candidate.bio && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">About</h2>
<p className="text-gray-600 whitespace-pre-wrap">{candidate.bio}</p>
</div>
</div>
)}
{candidate.skills && candidate.skills.length > 0 && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Skills</h2>
<div className="flex flex-wrap gap-2">
{candidate.skills.map((skill, index) => (
<span
key={index}
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800"
>
{skill}
</span>
))}
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Experience Level</h2>
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800">
{candidate.experience_level || 'Not specified'}
</span>
</div>
</div>
{candidate.salary_expectation && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Salary Expectation</h2>
<p className="text-lg font-medium text-gray-900">
${candidate.salary_expectation.toLocaleString()}
</p>
</div>
</div>
)}
</div>
</div>
);
};
export default CandidateDetails;

View File

@@ -0,0 +1,277 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from 'react-query';
import axios from 'axios';
import { Search, MapPin, User, Star } from 'lucide-react';
const Candidates = () => {
const [filters, setFilters] = useState({
search: '',
location: '',
experienceLevel: '',
skills: ''
});
const { data, isLoading, refetch } = useQuery(['candidates', filters], async () => {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value) params.append(key, value);
});
const response = await axios.get(`/api/candidates?${params.toString()}`);
return response.data;
});
const handleFilterChange = (e) => {
setFilters({
...filters,
[e.target.name]: e.target.value
});
};
const handleSearch = (e) => {
e.preventDefault();
refetch();
};
const getExperienceColor = (level) => {
switch (level) {
case 'entry': return 'bg-green-100 text-green-800';
case 'mid': return 'bg-blue-100 text-blue-800';
case 'senior': return 'bg-purple-100 text-purple-800';
case 'lead': return 'bg-orange-100 text-orange-800';
case 'executive': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Candidates</h1>
<p className="mt-1 text-sm text-gray-500">
Browse and discover talented candidates
</p>
</div>
{/* Filters */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<form onSubmit={handleSearch} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<label htmlFor="search" className="block text-sm font-medium text-gray-700">
Search
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
name="search"
id="search"
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="Name or keywords"
value={filters.search}
onChange={handleFilterChange}
/>
</div>
</div>
<div>
<label htmlFor="location" className="block text-sm font-medium text-gray-700">
Location
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MapPin className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
name="location"
id="location"
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="City, state, or country"
value={filters.location}
onChange={handleFilterChange}
/>
</div>
</div>
<div>
<label htmlFor="experienceLevel" className="block text-sm font-medium text-gray-700">
Experience Level
</label>
<select
id="experienceLevel"
name="experienceLevel"
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
value={filters.experienceLevel}
onChange={handleFilterChange}
>
<option value="">All Levels</option>
<option value="entry">Entry Level</option>
<option value="mid">Mid Level</option>
<option value="senior">Senior Level</option>
<option value="lead">Lead</option>
<option value="executive">Executive</option>
</select>
</div>
<div>
<label htmlFor="skills" className="block text-sm font-medium text-gray-700">
Skills
</label>
<input
type="text"
name="skills"
id="skills"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="JavaScript, React, etc."
value={filters.skills}
onChange={handleFilterChange}
/>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
className="btn btn-primary"
>
Search Candidates
</button>
</div>
</form>
</div>
</div>
{/* Candidates List */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{data?.candidates?.length > 0 ? (
data.candidates.map((candidate) => (
<div key={candidate.id} className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="h-12 w-12 rounded-full bg-primary-100 flex items-center justify-center">
<User className="h-6 w-6 text-primary-600" />
</div>
</div>
<div className="ml-4 flex-1">
<h3 className="text-lg font-medium text-gray-900">
<Link to={`/candidates/${candidate.id}`} className="hover:text-primary-600">
{candidate.first_name} {candidate.last_name}
</Link>
</h3>
<p className="text-sm text-gray-500">{candidate.email}</p>
{candidate.location && (
<div className="mt-2 flex items-center text-sm text-gray-500">
<MapPin className="h-4 w-4 mr-1" />
{candidate.location}
</div>
)}
{candidate.experience_level && (
<div className="mt-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getExperienceColor(candidate.experience_level)}`}>
{candidate.experience_level} level
</span>
</div>
)}
{candidate.bio && (
<div className="mt-3">
<p className="text-sm text-gray-600 line-clamp-3">
{candidate.bio}
</p>
</div>
)}
{candidate.skills && candidate.skills.length > 0 && (
<div className="mt-3">
<div className="flex flex-wrap gap-1">
{candidate.skills.slice(0, 4).map((skill, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800"
>
{skill}
</span>
))}
{candidate.skills.length > 4 && (
<span className="text-xs text-gray-500">
+{candidate.skills.length - 4} more
</span>
)}
</div>
</div>
)}
{candidate.salary_expectation && (
<div className="mt-3 flex items-center text-sm text-gray-500">
<Star className="h-4 w-4 mr-1" />
Expected: ${candidate.salary_expectation?.toLocaleString()}
</div>
)}
</div>
</div>
</div>
</div>
))
) : (
<div className="col-span-full text-center py-12">
<User className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No candidates found</h3>
<p className="mt-1 text-sm text-gray-500">
Try adjusting your search criteria
</p>
</div>
)}
</div>
{/* Pagination */}
{data?.pagination && data.pagination.pages > 1 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<button className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Previous
</button>
<button className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing{' '}
<span className="font-medium">
{((data.pagination.page - 1) * data.pagination.limit) + 1}
</span>{' '}
to{' '}
<span className="font-medium">
{Math.min(data.pagination.page * data.pagination.limit, data.pagination.total)}
</span>{' '}
of{' '}
<span className="font-medium">{data.pagination.total}</span>{' '}
results
</p>
</div>
</div>
</div>
)}
</div>
);
};
export default Candidates;

View File

@@ -0,0 +1,464 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useMutation } from 'react-query';
import axios from 'axios';
import toast from 'react-hot-toast';
import { ArrowLeft, Save, Plus, X } from 'lucide-react';
const CreateJob = () => {
const { user } = useAuth();
const navigate = useNavigate();
const [formData, setFormData] = useState({
title: '',
description: '',
requirements: [''],
responsibilities: [''],
location: '',
employmentType: 'full-time',
salaryMin: '',
salaryMax: '',
currency: 'USD',
remoteAllowed: false,
experienceLevel: '',
skillsRequired: [''],
benefits: [''],
applicationDeadline: ''
});
const createJobMutation = useMutation(async (jobData) => {
const response = await axios.post('/api/jobs', jobData);
return response.data;
}, {
onSuccess: () => {
toast.success('Job posted successfully!');
navigate('/jobs');
},
onError: (error) => {
toast.error(error.response?.data?.error || 'Failed to create job');
}
});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
const handleArrayChange = (field, index, value) => {
setFormData(prev => ({
...prev,
[field]: prev[field].map((item, i) => i === index ? value : item)
}));
};
const addArrayItem = (field) => {
setFormData(prev => ({
...prev,
[field]: [...prev[field], '']
}));
};
const removeArrayItem = (field, index) => {
setFormData(prev => ({
...prev,
[field]: prev[field].filter((_, i) => i !== index)
}));
};
const handleSubmit = (e) => {
e.preventDefault();
// Filter out empty array items
const cleanedData = {
...formData,
requirements: formData.requirements.filter(req => req.trim()),
responsibilities: formData.responsibilities.filter(resp => resp.trim()),
skillsRequired: formData.skillsRequired.filter(skill => skill.trim()),
benefits: formData.benefits.filter(benefit => benefit.trim()),
salaryMin: formData.salaryMin ? parseInt(formData.salaryMin) : undefined,
salaryMax: formData.salaryMax ? parseInt(formData.salaryMax) : undefined,
applicationDeadline: formData.applicationDeadline || undefined
};
createJobMutation.mutate(cleanedData);
};
return (
<div className="space-y-6">
<div className="flex items-center">
<button
onClick={() => navigate('/jobs')}
className="mr-4 p-2 text-gray-400 hover:text-gray-600"
>
<ArrowLeft className="h-5 w-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">Post a New Job</h1>
<p className="mt-1 text-sm text-gray-500">
Create a job posting to attract qualified candidates
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Information */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Basic Information</h3>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div className="sm:col-span-2">
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
Job Title *
</label>
<input
type="text"
name="title"
id="title"
required
className="mt-1 input"
value={formData.title}
onChange={handleChange}
placeholder="e.g., Senior Software Engineer"
/>
</div>
<div className="sm:col-span-2">
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
Job Description *
</label>
<textarea
name="description"
id="description"
rows={4}
required
className="mt-1 input"
value={formData.description}
onChange={handleChange}
placeholder="Describe the role, company culture, and what makes this opportunity special..."
/>
</div>
<div>
<label htmlFor="location" className="block text-sm font-medium text-gray-700">
Location *
</label>
<input
type="text"
name="location"
id="location"
required
className="mt-1 input"
value={formData.location}
onChange={handleChange}
placeholder="e.g., San Francisco, CA"
/>
</div>
<div>
<label htmlFor="employmentType" className="block text-sm font-medium text-gray-700">
Employment Type *
</label>
<select
name="employmentType"
id="employmentType"
required
className="mt-1 input"
value={formData.employmentType}
onChange={handleChange}
>
<option value="full-time">Full-time</option>
<option value="part-time">Part-time</option>
<option value="contract">Contract</option>
<option value="internship">Internship</option>
</select>
</div>
</div>
</div>
</div>
{/* Requirements and Responsibilities */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Requirements & Responsibilities</h3>
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Requirements
</label>
{formData.requirements.map((req, index) => (
<div key={index} className="flex items-center mb-2">
<input
type="text"
className="input flex-1"
value={req}
onChange={(e) => handleArrayChange('requirements', index, e.target.value)}
placeholder="e.g., 5+ years of experience in React"
/>
{formData.requirements.length > 1 && (
<button
type="button"
onClick={() => removeArrayItem('requirements', index)}
className="ml-2 p-2 text-red-600 hover:text-red-800"
>
<X className="h-4 w-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={() => addArrayItem('requirements')}
className="btn btn-secondary text-sm"
>
<Plus className="h-4 w-4 mr-1" />
Add Requirement
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Responsibilities
</label>
{formData.responsibilities.map((resp, index) => (
<div key={index} className="flex items-center mb-2">
<input
type="text"
className="input flex-1"
value={resp}
onChange={(e) => handleArrayChange('responsibilities', index, e.target.value)}
placeholder="e.g., Develop and maintain web applications"
/>
{formData.responsibilities.length > 1 && (
<button
type="button"
onClick={() => removeArrayItem('responsibilities', index)}
className="ml-2 p-2 text-red-600 hover:text-red-800"
>
<X className="h-4 w-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={() => addArrayItem('responsibilities')}
className="btn btn-secondary text-sm"
>
<Plus className="h-4 w-4 mr-1" />
Add Responsibility
</button>
</div>
</div>
</div>
</div>
{/* Compensation */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Compensation</h3>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
<div>
<label htmlFor="salaryMin" className="block text-sm font-medium text-gray-700">
Minimum Salary
</label>
<input
type="number"
name="salaryMin"
id="salaryMin"
className="mt-1 input"
value={formData.salaryMin}
onChange={handleChange}
placeholder="e.g., 80000"
/>
</div>
<div>
<label htmlFor="salaryMax" className="block text-sm font-medium text-gray-700">
Maximum Salary
</label>
<input
type="number"
name="salaryMax"
id="salaryMax"
className="mt-1 input"
value={formData.salaryMax}
onChange={handleChange}
placeholder="e.g., 120000"
/>
</div>
<div>
<label htmlFor="currency" className="block text-sm font-medium text-gray-700">
Currency
</label>
<select
name="currency"
id="currency"
className="mt-1 input"
value={formData.currency}
onChange={handleChange}
>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
<option value="CAD">CAD</option>
</select>
</div>
</div>
</div>
</div>
{/* Additional Details */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Additional Details</h3>
<div className="space-y-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label htmlFor="experienceLevel" className="block text-sm font-medium text-gray-700">
Experience Level
</label>
<select
name="experienceLevel"
id="experienceLevel"
className="mt-1 input"
value={formData.experienceLevel}
onChange={handleChange}
>
<option value="">Select level</option>
<option value="entry">Entry Level</option>
<option value="mid">Mid Level</option>
<option value="senior">Senior Level</option>
<option value="lead">Lead</option>
<option value="executive">Executive</option>
</select>
</div>
<div>
<label htmlFor="applicationDeadline" className="block text-sm font-medium text-gray-700">
Application Deadline
</label>
<input
type="date"
name="applicationDeadline"
id="applicationDeadline"
className="mt-1 input"
value={formData.applicationDeadline}
onChange={handleChange}
/>
</div>
</div>
<div className="flex items-center">
<input
type="checkbox"
name="remoteAllowed"
id="remoteAllowed"
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
checked={formData.remoteAllowed}
onChange={handleChange}
/>
<label htmlFor="remoteAllowed" className="ml-2 block text-sm text-gray-900">
Remote work allowed
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Required Skills
</label>
{formData.skillsRequired.map((skill, index) => (
<div key={index} className="flex items-center mb-2">
<input
type="text"
className="input flex-1"
value={skill}
onChange={(e) => handleArrayChange('skillsRequired', index, e.target.value)}
placeholder="e.g., JavaScript, React, Node.js"
/>
{formData.skillsRequired.length > 1 && (
<button
type="button"
onClick={() => removeArrayItem('skillsRequired', index)}
className="ml-2 p-2 text-red-600 hover:text-red-800"
>
<X className="h-4 w-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={() => addArrayItem('skillsRequired')}
className="btn btn-secondary text-sm"
>
<Plus className="h-4 w-4 mr-1" />
Add Skill
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Benefits
</label>
{formData.benefits.map((benefit, index) => (
<div key={index} className="flex items-center mb-2">
<input
type="text"
className="input flex-1"
value={benefit}
onChange={(e) => handleArrayChange('benefits', index, e.target.value)}
placeholder="e.g., Health insurance, 401k, Flexible hours"
/>
{formData.benefits.length > 1 && (
<button
type="button"
onClick={() => removeArrayItem('benefits', index)}
className="ml-2 p-2 text-red-600 hover:text-red-800"
>
<X className="h-4 w-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={() => addArrayItem('benefits')}
className="btn btn-secondary text-sm"
>
<Plus className="h-4 w-4 mr-1" />
Add Benefit
</button>
</div>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => navigate('/jobs')}
className="btn btn-secondary"
>
Cancel
</button>
<button
type="submit"
disabled={createJobMutation.isLoading}
className="btn btn-primary disabled:opacity-50"
>
<Save className="h-4 w-4 mr-2" />
{createJobMutation.isLoading ? 'Creating...' : 'Post Job'}
</button>
</div>
</form>
</div>
);
};
export default CreateJob;

View File

@@ -0,0 +1,284 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useQuery } from 'react-query';
import axios from 'axios';
import {
Briefcase,
Users,
FileText,
Building,
TrendingUp,
Clock,
CheckCircle,
AlertCircle
} from 'lucide-react';
const Dashboard = () => {
const { user } = useAuth();
const { data: stats, isLoading } = useQuery('dashboard-stats', async () => {
const [jobsRes, applicationsRes, candidatesRes, employersRes] = await Promise.all([
axios.get('/api/jobs?limit=1'),
axios.get('/api/applications?limit=1'),
user?.role === 'candidate' ? Promise.resolve({ data: { applications: { pagination: { total: 0 } } } }) : axios.get('/api/applications?limit=1'),
user?.role === 'employer' || user?.role === 'admin' || user?.role === 'recruiter' ? axios.get('/api/employers?limit=1') : Promise.resolve({ data: [] })
]);
return {
totalJobs: jobsRes.data.pagination?.total || 0,
totalApplications: applicationsRes.data.pagination?.total || 0,
totalCandidates: candidatesRes.data.pagination?.total || 0,
totalEmployers: employersRes.data.length || 0
};
});
const { data: recentJobs } = useQuery('recent-jobs', async () => {
const response = await axios.get('/api/jobs?limit=5');
return response.data.jobs;
});
const { data: recentApplications } = useQuery('recent-applications', async () => {
if (user?.role === 'candidate') {
const response = await axios.get('/api/applications?limit=5');
return response.data.applications;
}
return [];
});
const statsCards = [
{
name: 'Total Jobs',
value: stats?.totalJobs || 0,
icon: Briefcase,
color: 'bg-blue-500'
},
{
name: 'Applications',
value: stats?.totalApplications || 0,
icon: FileText,
color: 'bg-green-500'
},
{
name: 'Candidates',
value: stats?.totalCandidates || 0,
icon: Users,
color: 'bg-purple-500'
},
{
name: 'Employers',
value: stats?.totalEmployers || 0,
icon: Building,
color: 'bg-orange-500'
}
];
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">
{getGreeting()}, {user?.firstName}!
</h1>
<p className="mt-1 text-sm text-gray-500">
Welcome to your MysteryApp-Cursor dashboard
</p>
<div className="text-xs text-gray-500 mt-2">
Debug: User role = {user?.role || 'undefined'}, User ID = {user?.id || 'undefined'}
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
{statsCards.map((card) => (
<div key={card.name} className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className={`p-3 rounded-md ${card.color}`}>
<card.icon className="h-6 w-6 text-white" />
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
{card.name}
</dt>
<dd className="text-lg font-medium text-gray-900">
{card.value}
</dd>
</dl>
</div>
</div>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Recent Jobs */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Recent Job Postings
</h3>
<div className="mt-5">
{recentJobs?.length > 0 ? (
<div className="space-y-3">
{recentJobs.map((job) => (
<div key={job.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{job.title}
</p>
<p className="text-sm text-gray-500">
{job.company_name}
</p>
</div>
<div className="flex items-center text-sm text-gray-500">
<Clock className="h-4 w-4 mr-1" />
{new Date(job.created_at).toLocaleDateString()}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">No recent jobs found</p>
)}
</div>
</div>
</div>
{/* Recent Applications */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Recent Applications
</h3>
<div className="mt-5">
{recentApplications?.length > 0 ? (
<div className="space-y-3">
{recentApplications.map((application) => (
<div key={application.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{application.job_title}
</p>
<p className="text-sm text-gray-500">
{application.company_name}
</p>
</div>
<div className="flex items-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
application.status === 'applied' ? 'bg-blue-100 text-blue-800' :
application.status === 'reviewed' ? 'bg-yellow-100 text-yellow-800' :
application.status === 'shortlisted' ? 'bg-green-100 text-green-800' :
application.status === 'rejected' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
{application.status}
</span>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">No recent applications found</p>
)}
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Quick Actions
</h3>
<div className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{user?.role === 'employer' && (
<Link
to="/jobs/create"
className="relative group bg-white p-6 focus-within:ring-2 focus-within:ring-inset focus-within:ring-primary-500 rounded-lg border border-gray-200 hover:border-gray-300"
>
<div>
<span className="rounded-lg inline-flex p-3 bg-primary-50 text-primary-700 ring-4 ring-white">
<Briefcase className="h-6 w-6" />
</span>
</div>
<div className="mt-8">
<h3 className="text-lg font-medium">
<span className="absolute inset-0" />
Post a Job
</h3>
<p className="mt-2 text-sm text-gray-500">
Create a new job posting to attract candidates
</p>
</div>
</Link>
)}
{user?.role === 'candidate' && (
<a
href="/jobs"
className="relative group bg-white p-6 focus-within:ring-2 focus-within:ring-inset focus-within:ring-primary-500 rounded-lg border border-gray-200 hover:border-gray-300"
>
<div>
<span className="rounded-lg inline-flex p-3 bg-primary-50 text-primary-700 ring-4 ring-white">
<TrendingUp className="h-6 w-6" />
</span>
</div>
<div className="mt-8">
<h3 className="text-lg font-medium">
<span className="absolute inset-0" />
Browse Jobs
</h3>
<p className="mt-2 text-sm text-gray-500">
Find your next career opportunity
</p>
</div>
</a>
)}
<a
href="/applications"
className="relative group bg-white p-6 focus-within:ring-2 focus-within:ring-inset focus-within:ring-primary-500 rounded-lg border border-gray-200 hover:border-gray-300"
>
<div>
<span className="rounded-lg inline-flex p-3 bg-primary-50 text-primary-700 ring-4 ring-white">
<FileText className="h-6 w-6" />
</span>
</div>
<div className="mt-8">
<h3 className="text-lg font-medium">
<span className="absolute inset-0" />
View Applications
</h3>
<p className="mt-2 text-sm text-gray-500">
Track your application status
</p>
</div>
</a>
</div>
</div>
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from 'react-query';
import axios from 'axios';
import { Building, MapPin, Users, Globe, Mail, Phone } from 'lucide-react';
const EmployerDetails = () => {
const { id } = useParams();
const { data: employer, isLoading } = useQuery(['employer', id], async () => {
const response = await axios.get(`/api/employers/${id}`);
return response.data;
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
</div>
);
}
if (!employer) {
return (
<div className="text-center py-12">
<h3 className="text-lg font-medium text-gray-900">Employer not found</h3>
<p className="mt-1 text-sm text-gray-500">
The employer you're looking for doesn't exist.
</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="h-16 w-16 rounded-full bg-primary-100 flex items-center justify-center">
<Building className="h-8 w-8 text-primary-600" />
</div>
</div>
<div className="ml-6 flex-1">
<h1 className="text-2xl font-bold text-gray-900">
{employer.company_name}
</h1>
<p className="text-lg text-gray-600">{employer.first_name} {employer.last_name}</p>
<p className="text-sm text-gray-500">{employer.email}</p>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
{employer.industry && (
<div className="flex items-center text-sm text-gray-500">
<Building className="h-4 w-4 mr-2" />
{employer.industry}
</div>
)}
{employer.company_size && (
<div className="flex items-center text-sm text-gray-500">
<Users className="h-4 w-4 mr-2" />
{employer.company_size} employees
</div>
)}
{employer.website && (
<div className="flex items-center text-sm text-gray-500">
<Globe className="h-4 w-4 mr-2" />
<a href={employer.website} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500">
Website
</a>
</div>
)}
{employer.phone && (
<div className="flex items-center text-sm text-gray-500">
<Phone className="h-4 w-4 mr-2" />
{employer.phone}
</div>
)}
</div>
</div>
</div>
</div>
</div>
{employer.description && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">About {employer.company_name}</h2>
<p className="text-gray-600 whitespace-pre-wrap">{employer.description}</p>
</div>
</div>
)}
{employer.address && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Address</h2>
<div className="flex items-start">
<MapPin className="h-5 w-5 text-gray-400 mr-3 mt-0.5" />
<p className="text-gray-600">{employer.address}</p>
</div>
</div>
</div>
)}
</div>
);
};
export default EmployerDetails;

View File

@@ -0,0 +1,98 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from 'react-query';
import axios from 'axios';
import { Building, MapPin, Users, Globe } from 'lucide-react';
const Employers = () => {
const { data, isLoading } = useQuery('employers', async () => {
const response = await axios.get('/api/employers');
return response.data;
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Employers</h1>
<p className="mt-1 text-sm text-gray-500">
Browse companies and employers
</p>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{data?.length > 0 ? (
data.map((employer) => (
<div key={employer.id} className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="h-12 w-12 rounded-full bg-primary-100 flex items-center justify-center">
<Building className="h-6 w-6 text-primary-600" />
</div>
</div>
<div className="ml-4 flex-1">
<h3 className="text-lg font-medium text-gray-900">
<Link to={`/employers/${employer.id}`} className="hover:text-primary-600">
{employer.company_name}
</Link>
</h3>
<p className="text-sm text-gray-500">{employer.first_name} {employer.last_name}</p>
{employer.industry && (
<div className="mt-2 flex items-center text-sm text-gray-500">
<Building className="h-4 w-4 mr-1" />
{employer.industry}
</div>
)}
{employer.company_size && (
<div className="mt-1 flex items-center text-sm text-gray-500">
<Users className="h-4 w-4 mr-1" />
{employer.company_size} employees
</div>
)}
{employer.website && (
<div className="mt-1 flex items-center text-sm text-gray-500">
<Globe className="h-4 w-4 mr-1" />
<a href={employer.website} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-500">
Website
</a>
</div>
)}
{employer.description && (
<div className="mt-3">
<p className="text-sm text-gray-600 line-clamp-3">
{employer.description}
</p>
</div>
)}
</div>
</div>
</div>
</div>
))
) : (
<div className="col-span-full text-center py-12">
<Building className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No employers found</h3>
<p className="mt-1 text-sm text-gray-500">
No employers have registered yet.
</p>
</div>
)}
</div>
</div>
);
};
export default Employers;

View File

@@ -0,0 +1,293 @@
import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery } from 'react-query';
import axios from 'axios';
import { useAuth } from '../contexts/AuthContext';
import { MapPin, Clock, DollarSign, Briefcase, Users, Calendar } from 'lucide-react';
import toast from 'react-hot-toast';
const JobDetails = () => {
const { id } = useParams();
const { user } = useAuth();
const [applying, setApplying] = useState(false);
const [coverLetter, setCoverLetter] = useState('');
const { data: job, isLoading } = useQuery(['job', id], async () => {
const response = await axios.get(`/api/jobs/${id}`);
return response.data;
});
const handleApply = async () => {
if (!coverLetter.trim()) {
toast.error('Please provide a cover letter');
return;
}
setApplying(true);
try {
await axios.post('/api/applications', {
jobId: id,
coverLetter: coverLetter.trim()
});
toast.success('Application submitted successfully!');
setCoverLetter('');
} catch (error) {
toast.error(error.response?.data?.error || 'Failed to submit application');
} finally {
setApplying(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
</div>
);
}
if (!job) {
return (
<div className="text-center py-12">
<h3 className="text-lg font-medium text-gray-900">Job not found</h3>
<p className="mt-1 text-sm text-gray-500">
The job you're looking for doesn't exist or has been removed.
</p>
<div className="mt-6">
<Link to="/jobs" className="btn btn-primary">
Browse Jobs
</Link>
</div>
</div>
);
}
const formatSalary = (min, max, currency = 'USD') => {
if (!min && !max) return 'Salary not specified';
if (!min) return `Up to ${currency} ${max?.toLocaleString()}`;
if (!max) return `From ${currency} ${min?.toLocaleString()}`;
return `${currency} ${min?.toLocaleString()} - ${max?.toLocaleString()}`;
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Link to="/jobs" className="text-sm text-primary-600 hover:text-primary-500">
Back to Jobs
</Link>
<h1 className="mt-2 text-3xl font-bold text-gray-900">{job.title}</h1>
<div className="mt-2 flex items-center text-lg text-gray-600">
<Briefcase className="h-5 w-5 mr-2" />
{job.company_name}
</div>
</div>
{user?.role === 'candidate' && job.status === 'active' && (
<div className="flex space-x-3">
<button
onClick={handleApply}
disabled={applying}
className="btn btn-primary disabled:opacity-50"
>
{applying ? 'Applying...' : 'Apply Now'}
</button>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{/* Job Description */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Job Description</h2>
<div className="prose max-w-none">
<p className="text-gray-600 whitespace-pre-wrap">{job.description}</p>
</div>
</div>
</div>
{/* Requirements */}
{job.requirements && job.requirements.length > 0 && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Requirements</h2>
<ul className="list-disc list-inside space-y-2">
{job.requirements.map((requirement, index) => (
<li key={index} className="text-gray-600">{requirement}</li>
))}
</ul>
</div>
</div>
)}
{/* Responsibilities */}
{job.responsibilities && job.responsibilities.length > 0 && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Responsibilities</h2>
<ul className="list-disc list-inside space-y-2">
{job.responsibilities.map((responsibility, index) => (
<li key={index} className="text-gray-600">{responsibility}</li>
))}
</ul>
</div>
</div>
)}
{/* Skills Required */}
{job.skills_required && job.skills_required.length > 0 && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Required Skills</h2>
<div className="flex flex-wrap gap-2">
{job.skills_required.map((skill, index) => (
<span
key={index}
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800"
>
{skill}
</span>
))}
</div>
</div>
</div>
)}
{/* Benefits */}
{job.benefits && job.benefits.length > 0 && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Benefits</h2>
<ul className="list-disc list-inside space-y-2">
{job.benefits.map((benefit, index) => (
<li key={index} className="text-gray-600">{benefit}</li>
))}
</ul>
</div>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Job Details */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Job Details</h3>
<div className="space-y-4">
<div className="flex items-center">
<MapPin className="h-5 w-5 text-gray-400 mr-3" />
<div>
<p className="text-sm font-medium text-gray-900">{job.location}</p>
{job.remote_allowed && (
<p className="text-sm text-gray-500">Remote work allowed</p>
)}
</div>
</div>
<div className="flex items-center">
<Briefcase className="h-5 w-5 text-gray-400 mr-3" />
<div>
<p className="text-sm font-medium text-gray-900 capitalize">
{job.employment_type?.replace('-', ' ')}
</p>
</div>
</div>
<div className="flex items-center">
<DollarSign className="h-5 w-5 text-gray-400 mr-3" />
<div>
<p className="text-sm font-medium text-gray-900">
{formatSalary(job.salary_min, job.salary_max, job.currency)}
</p>
</div>
</div>
{job.experience_level && (
<div className="flex items-center">
<Users className="h-5 w-5 text-gray-400 mr-3" />
<div>
<p className="text-sm font-medium text-gray-900 capitalize">
{job.experience_level} level
</p>
</div>
</div>
)}
<div className="flex items-center">
<Clock className="h-5 w-5 text-gray-400 mr-3" />
<div>
<p className="text-sm font-medium text-gray-900">
Posted {new Date(job.created_at).toLocaleDateString()}
</p>
</div>
</div>
{job.application_deadline && (
<div className="flex items-center">
<Calendar className="h-5 w-5 text-gray-400 mr-3" />
<div>
<p className="text-sm font-medium text-gray-900">
Apply by {new Date(job.application_deadline).toLocaleDateString()}
</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* Company Info */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Company</h3>
<div className="space-y-2">
<p className="text-sm font-medium text-gray-900">{job.company_name}</p>
{job.industry && (
<p className="text-sm text-gray-500">{job.industry}</p>
)}
{job.company_size && (
<p className="text-sm text-gray-500">{job.company_size} employees</p>
)}
</div>
</div>
</div>
{/* Apply Section for Candidates */}
{user?.role === 'candidate' && job.status === 'active' && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Apply for this job</h3>
<div className="space-y-4">
<div>
<label htmlFor="coverLetter" className="block text-sm font-medium text-gray-700">
Cover Letter
</label>
<textarea
id="coverLetter"
rows={4}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="Tell us why you're interested in this position..."
value={coverLetter}
onChange={(e) => setCoverLetter(e.target.value)}
/>
</div>
<button
onClick={handleApply}
disabled={applying || !coverLetter.trim()}
className="w-full btn btn-primary disabled:opacity-50"
>
{applying ? 'Applying...' : 'Submit Application'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default JobDetails;

295
frontend/src/pages/Jobs.js Normal file
View File

@@ -0,0 +1,295 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from 'react-query';
import axios from 'axios';
import { useAuth } from '../contexts/AuthContext';
import { Search, MapPin, Clock, DollarSign, Briefcase, Plus } from 'lucide-react';
const Jobs = () => {
const { user } = useAuth();
const [filters, setFilters] = useState({
search: '',
location: '',
employmentType: '',
experienceLevel: ''
});
const { data, isLoading, refetch } = useQuery(['jobs', filters], async () => {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value) params.append(key, value);
});
const response = await axios.get(`/api/jobs?${params.toString()}`);
return response.data;
});
const handleFilterChange = (e) => {
setFilters({
...filters,
[e.target.name]: e.target.value
});
};
const handleSearch = (e) => {
e.preventDefault();
refetch();
};
const formatSalary = (min, max, currency = 'USD') => {
if (!min && !max) return 'Salary not specified';
if (!min) return `Up to ${currency} ${max?.toLocaleString()}`;
if (!max) return `From ${currency} ${min?.toLocaleString()}`;
return `${currency} ${min?.toLocaleString()} - ${max?.toLocaleString()}`;
};
const getStatusColor = (status) => {
switch (status) {
case 'active': return 'bg-green-100 text-green-800';
case 'paused': return 'bg-yellow-100 text-yellow-800';
case 'closed': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Job Postings</h1>
<p className="mt-1 text-sm text-gray-500">
Find your next career opportunity
</p>
</div>
{/* Debug info */}
<div className="text-xs text-gray-500 mb-2">
Debug: User role = {user?.role || 'undefined'}
</div>
{(user?.role === 'employer' || user?.role === 'recruiter') && (
<Link
to="/jobs/create"
className="btn btn-primary"
>
<Plus className="h-4 w-4 mr-2" />
Post a Job
</Link>
)}
</div>
{/* Filters */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<form onSubmit={handleSearch} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<label htmlFor="search" className="block text-sm font-medium text-gray-700">
Search
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
name="search"
id="search"
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="Job title or keywords"
value={filters.search}
onChange={handleFilterChange}
/>
</div>
</div>
<div>
<label htmlFor="location" className="block text-sm font-medium text-gray-700">
Location
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MapPin className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
name="location"
id="location"
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="City, state, or remote"
value={filters.location}
onChange={handleFilterChange}
/>
</div>
</div>
<div>
<label htmlFor="employmentType" className="block text-sm font-medium text-gray-700">
Employment Type
</label>
<select
id="employmentType"
name="employmentType"
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
value={filters.employmentType}
onChange={handleFilterChange}
>
<option value="">All Types</option>
<option value="full-time">Full-time</option>
<option value="part-time">Part-time</option>
<option value="contract">Contract</option>
<option value="internship">Internship</option>
</select>
</div>
<div>
<label htmlFor="experienceLevel" className="block text-sm font-medium text-gray-700">
Experience Level
</label>
<select
id="experienceLevel"
name="experienceLevel"
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
value={filters.experienceLevel}
onChange={handleFilterChange}
>
<option value="">All Levels</option>
<option value="entry">Entry Level</option>
<option value="mid">Mid Level</option>
<option value="senior">Senior Level</option>
<option value="lead">Lead</option>
<option value="executive">Executive</option>
</select>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
className="btn btn-primary"
>
Search Jobs
</button>
</div>
</form>
</div>
</div>
{/* Jobs List */}
<div className="space-y-4">
{data?.jobs?.length > 0 ? (
data.jobs.map((job) => (
<div key={job.id} className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center">
<h3 className="text-lg font-medium text-gray-900">
<Link to={`/jobs/${job.id}`} className="hover:text-primary-600">
{job.title}
</Link>
</h3>
<span className={`ml-3 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(job.status)}`}>
{job.status}
</span>
</div>
<div className="mt-1 flex items-center text-sm text-gray-500">
<Briefcase className="h-4 w-4 mr-1" />
{job.company_name}
</div>
<div className="mt-2 flex items-center text-sm text-gray-500 space-x-4">
<div className="flex items-center">
<MapPin className="h-4 w-4 mr-1" />
{job.location}
{job.remote_allowed && <span className="ml-1">(Remote OK)</span>}
</div>
<div className="flex items-center">
<DollarSign className="h-4 w-4 mr-1" />
{formatSalary(job.salary_min, job.salary_max, job.currency)}
</div>
<div className="flex items-center">
<Clock className="h-4 w-4 mr-1" />
{new Date(job.created_at).toLocaleDateString()}
</div>
</div>
<div className="mt-3">
<p className="text-sm text-gray-600 line-clamp-2">
{job.description}
</p>
</div>
{job.skills_required && job.skills_required.length > 0 && (
<div className="mt-3">
<div className="flex flex-wrap gap-2">
{job.skills_required.slice(0, 5).map((skill, index) => (
<span
key={index}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800"
>
{skill}
</span>
))}
{job.skills_required.length > 5 && (
<span className="text-xs text-gray-500">
+{job.skills_required.length - 5} more
</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
))
) : (
<div className="text-center py-12">
<Briefcase className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No jobs found</h3>
<p className="mt-1 text-sm text-gray-500">
Try adjusting your search criteria
</p>
</div>
)}
</div>
{/* Pagination */}
{data?.pagination && data.pagination.pages > 1 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<button className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Previous
</button>
<button className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing{' '}
<span className="font-medium">
{((data.pagination.page - 1) * data.pagination.limit) + 1}
</span>{' '}
to{' '}
<span className="font-medium">
{Math.min(data.pagination.page * data.pagination.limit, data.pagination.total)}
</span>{' '}
of{' '}
<span className="font-medium">{data.pagination.total}</span>{' '}
results
</p>
</div>
</div>
</div>
)}
</div>
);
};
export default Jobs;

136
frontend/src/pages/Login.js Normal file
View File

@@ -0,0 +1,136 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { Eye, EyeOff, Mail, Lock } from 'lucide-react';
const Login = () => {
const { login } = useAuth();
const [formData, setFormData] = useState({
email: '',
password: ''
});
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
const result = await login(formData.email, formData.password);
if (!result.success) {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
to="/register"
className="font-medium text-primary-600 hover:text-primary-500"
>
create a new account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only">
Email address
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Email address"
value={formData.email}
onChange={handleChange}
/>
</div>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 pl-10 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={formData.password}
onChange={handleChange}
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
<button
type="button"
className="text-gray-400 hover:text-gray-500"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
</div>
</div>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">
Demo accounts:
</p>
<div className="mt-2 text-xs text-gray-500 space-y-1">
<p>Admin: admin@mysteryapp.com / admin123</p>
<p>Recruiter: recruiter@mysteryapp.com / recruiter123</p>
<p>Employer: employer@techcorp.com / employer123</p>
<p>Candidate: candidate@example.com / candidate123</p>
</div>
</div>
</form>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,142 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { User, Mail, Save } from 'lucide-react';
import toast from 'react-hot-toast';
const Profile = () => {
const { user, fetchUser } = useAuth();
const [formData, setFormData] = useState({
firstName: user?.firstName || '',
lastName: user?.lastName || '',
email: user?.email || ''
});
const [loading, setLoading] = useState(false);
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await fetch(`/api/users/${user.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(formData)
});
await fetchUser();
toast.success('Profile updated successfully!');
} catch (error) {
toast.error('Failed to update profile');
} finally {
setLoading(false);
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Profile</h1>
<p className="mt-1 text-sm text-gray-500">
Manage your account information
</p>
</div>
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
First Name
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
name="firstName"
id="firstName"
required
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
value={formData.firstName}
onChange={handleChange}
/>
</div>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700">
Last Name
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
name="lastName"
id="lastName"
required
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
value={formData.lastName}
onChange={handleChange}
/>
</div>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email Address
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
type="email"
name="email"
id="email"
required
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
value={formData.email}
onChange={handleChange}
/>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 rounded-md">
<p className="text-sm text-gray-600">
<strong>Role:</strong> {user?.role}
</p>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={loading}
className="btn btn-primary disabled:opacity-50"
>
<Save className="h-4 w-4 mr-2" />
{loading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default Profile;

View File

@@ -0,0 +1,242 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { Eye, EyeOff, Mail, Lock, User } from 'lucide-react';
const Register = () => {
const { register } = useAuth();
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
role: 'candidate'
});
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [loading, setLoading] = useState(false);
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
const handleSubmit = async (e) => {
e.preventDefault();
if (formData.password !== formData.confirmPassword) {
alert('Passwords do not match');
return;
}
if (formData.password.length < 6) {
alert('Password must be at least 6 characters long');
return;
}
setLoading(true);
const result = await register({
firstName: formData.firstName,
lastName: formData.lastName,
email: formData.email,
password: formData.password,
role: formData.role
});
if (!result.success) {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
to="/login"
className="font-medium text-primary-600 hover:text-primary-500"
>
sign in to your existing account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700">
First Name
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-gray-400" />
</div>
<input
id="firstName"
name="firstName"
type="text"
required
className="appearance-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="First name"
value={formData.firstName}
onChange={handleChange}
/>
</div>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700">
Last Name
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-gray-400" />
</div>
<input
id="lastName"
name="lastName"
type="text"
required
className="appearance-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="Last name"
value={formData.lastName}
onChange={handleChange}
/>
</div>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email Address
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="appearance-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="Email address"
value={formData.email}
onChange={handleChange}
/>
</div>
</div>
<div>
<label htmlFor="role" className="block text-sm font-medium text-gray-700">
Account Type
</label>
<select
id="role"
name="role"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
value={formData.role}
onChange={handleChange}
>
<option value="candidate">Candidate</option>
<option value="employer">Employer</option>
<option value="recruiter">Recruiter</option>
</select>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
required
className="appearance-none relative block w-full px-3 py-2 pl-10 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="Password"
value={formData.password}
onChange={handleChange}
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
<button
type="button"
className="text-gray-400 hover:text-gray-500"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
</div>
</div>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm Password
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="confirmPassword"
name="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
autoComplete="new-password"
required
className="appearance-none relative block w-full px-3 py-2 pl-10 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
placeholder="Confirm password"
value={formData.confirmPassword}
onChange={handleChange}
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
<button
type="button"
className="text-gray-400 hover:text-gray-500"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
</div>
</div>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
</div>
</form>
</div>
</div>
);
};
export default Register;

View File

@@ -0,0 +1,245 @@
import React, { useState } from 'react';
import { useQuery } from 'react-query';
import axios from 'axios';
import { Upload, Download, Trash2, Star, FileText } from 'lucide-react';
import toast from 'react-hot-toast';
const Resumes = () => {
const [uploading, setUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const { data: resumes, isLoading, refetch } = useQuery('resumes', async () => {
// This would need to be implemented based on the candidate's ID
// For now, return empty array
return [];
});
const handleFileSelect = (e) => {
const file = e.target.files[0];
if (file) {
if (file.size > 10 * 1024 * 1024) { // 10MB limit
toast.error('File size must be less than 10MB');
return;
}
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain'
];
if (!allowedTypes.includes(file.type)) {
toast.error('Only PDF, DOC, DOCX, and TXT files are allowed');
return;
}
setSelectedFile(file);
}
};
const handleUpload = async () => {
if (!selectedFile) {
toast.error('Please select a file');
return;
}
setUploading(true);
const formData = new FormData();
formData.append('resume', selectedFile);
formData.append('isPrimary', resumes?.length === 0 ? 'true' : 'false');
try {
await axios.post('/api/resumes/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
toast.success('Resume uploaded successfully!');
setSelectedFile(null);
refetch();
} catch (error) {
toast.error(error.response?.data?.error || 'Failed to upload resume');
} finally {
setUploading(false);
}
};
const handleDownload = async (resumeId) => {
try {
const response = await axios.get(`/api/resumes/${resumeId}/download`, {
responseType: 'blob'
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', response.headers['content-disposition']?.split('filename=')[1] || 'resume.pdf');
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (error) {
toast.error('Failed to download resume');
}
};
const handleSetPrimary = async (resumeId) => {
try {
await axios.put(`/api/resumes/${resumeId}/primary`);
toast.success('Primary resume updated!');
refetch();
} catch (error) {
toast.error('Failed to set primary resume');
}
};
const handleDelete = async (resumeId) => {
if (!window.confirm('Are you sure you want to delete this resume?')) {
return;
}
try {
await axios.delete(`/api/resumes/${resumeId}`);
toast.success('Resume deleted successfully!');
refetch();
} catch (error) {
toast.error('Failed to delete resume');
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Resumes</h1>
<p className="mt-1 text-sm text-gray-500">
Manage your resume files
</p>
</div>
{/* Upload Section */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Upload Resume</h2>
<div className="space-y-4">
<div>
<label htmlFor="resume" className="block text-sm font-medium text-gray-700">
Select Resume File
</label>
<input
type="file"
id="resume"
accept=".pdf,.doc,.docx,.txt"
onChange={handleFileSelect}
className="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
/>
<p className="mt-1 text-xs text-gray-500">
PDF, DOC, DOCX, or TXT files only. Maximum size: 10MB
</p>
</div>
{selectedFile && (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center">
<FileText className="h-5 w-5 text-gray-400 mr-2" />
<span className="text-sm text-gray-900">{selectedFile.name}</span>
<span className="ml-2 text-xs text-gray-500">
({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
</span>
</div>
<button
onClick={handleUpload}
disabled={uploading}
className="btn btn-primary disabled:opacity-50"
>
<Upload className="h-4 w-4 mr-2" />
{uploading ? 'Uploading...' : 'Upload'}
</button>
</div>
)}
</div>
</div>
</div>
{/* Resumes List */}
<div className="space-y-4">
{resumes?.length > 0 ? (
resumes.map((resume) => (
<div key={resume.id} className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center justify-between">
<div className="flex items-center">
<FileText className="h-8 w-8 text-gray-400 mr-3" />
<div>
<h3 className="text-lg font-medium text-gray-900">
{resume.original_name}
</h3>
<div className="flex items-center text-sm text-gray-500">
<span>{(resume.file_size / 1024 / 1024).toFixed(2)} MB</span>
<span className="mx-2"></span>
<span>Uploaded {new Date(resume.uploaded_at).toLocaleDateString()}</span>
{resume.is_primary && (
<>
<span className="mx-2"></span>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
<Star className="h-3 w-3 mr-1" />
Primary
</span>
</>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => handleDownload(resume.id)}
className="btn btn-secondary"
>
<Download className="h-4 w-4 mr-2" />
Download
</button>
{!resume.is_primary && (
<button
onClick={() => handleSetPrimary(resume.id)}
className="btn btn-secondary"
>
<Star className="h-4 w-4 mr-2" />
Set Primary
</button>
)}
<button
onClick={() => handleDelete(resume.id)}
className="btn btn-danger"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</button>
</div>
</div>
</div>
</div>
))
) : (
<div className="text-center py-12">
<FileText className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No resumes uploaded</h3>
<p className="mt-1 text-sm text-gray-500">
Upload your first resume to get started.
</p>
</div>
)}
</div>
</div>
);
};
export default Resumes;

View File

@@ -0,0 +1,25 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
},
},
},
plugins: [],
}