const express = require('express'); const session = require('express-session'); const bcrypt = require('bcrypt'); const { Pool } = require('pg'); const multer = require('multer'); const path = require('path'); const cookieParser = require('cookie-parser'); const app = express(); const PORT = process.env.PORT || 3000; // PostgreSQL connection const pool = new Pool({ host: process.env.DB_HOST || 'postgres', port: process.env.DB_PORT || 5432, database: process.env.DB_NAME || 'recruitment', user: process.env.DB_USER || 'postgres', password: process.env.DB_PASSWORD || 'postgres', max: 20, idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000, }); // Test database connection pool.query('SELECT NOW()', (err, res) => { if (err) { console.error('Database connection error:', err); } else { console.log('Database connected successfully at:', res.rows[0].now); } }); // Middleware app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); app.use(cookieParser()); app.use(session({ secret: process.env.SESSION_SECRET || 'recruitment-site-secret-key-change-in-production', resave: false, saveUninitialized: false, cookie: { secure: false, // Set to true if using HTTPS httpOnly: true, maxAge: 24 * 60 * 60 * 1000 // 24 hours } })); // Middleware to handle routes without .html extension app.use((req, res, next) => { if (req.path.indexOf('.') === -1 && !req.path.startsWith('/api/') && !req.path.startsWith('/admin/')) { const file = `${req.path}.html`; res.sendFile(path.join(__dirname, 'public', file), (err) => { if (err) next(); }); } else if (req.path.indexOf('.') === -1 && req.path.startsWith('/admin/') && req.path !== '/admin/') { const file = `${req.path}.html`; res.sendFile(path.join(__dirname, 'public', file), (err) => { if (err) next(); }); } else { next(); } }); // Serve static files app.use(express.static('public')); // Configure multer for file uploads (memory storage for CV uploads) const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024, // 5MB limit }, fileFilter: (req, file, cb) => { const allowedTypes = /pdf|doc|docx/; const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); const mimetype = allowedTypes.test(file.mimetype) || file.mimetype === 'application/msword' || file.mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; if (mimetype && extname) { return cb(null, true); } cb(new Error('Only PDF, DOC, and DOCX files are allowed')); } }); // Auth middleware const requireAuth = (req, res, next) => { if (req.session.adminId) { next(); } else { res.status(401).json({ error: 'Unauthorized' }); } }; // ============================================ // PUBLIC API ENDPOINTS // ============================================ // Get all active job postings app.get('/api/jobs', async (req, res) => { try { const result = await pool.query( 'SELECT id, title, department, location, employment_type, salary_range, description, requirements, benefits, created_at FROM job_postings WHERE is_active = true ORDER BY created_at DESC' ); res.json(result.rows); } catch (err) { console.error('Error fetching jobs:', err); res.status(500).json({ error: 'Failed to fetch jobs' }); } }); // Get single job posting app.get('/api/jobs/:id', async (req, res) => { try { const result = await pool.query( 'SELECT id, title, department, location, employment_type, salary_range, description, requirements, benefits, created_at FROM job_postings WHERE id = $1 AND is_active = true', [req.params.id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Job not found' }); } res.json(result.rows[0]); } catch (err) { console.error('Error fetching job:', err); res.status(500).json({ error: 'Failed to fetch job' }); } }); // Submit job application with CV app.post('/api/apply', upload.single('cv'), async (req, res) => { const client = await pool.connect(); try { await client.query('BEGIN'); const { fullName, email, phone, linkedinUrl, portfolioUrl, yearsOfExperience, currentPosition, currentCompany, preferredLocation, jobId, coverLetter } = req.body; // Validate required fields if (!fullName || !email || !req.file) { return res.status(400).json({ error: 'Name, email, and CV are required' }); } // Insert applicant const applicantResult = await client.query( `INSERT INTO applicants (full_name, email, phone, linkedin_url, portfolio_url, years_of_experience, current_position, current_company, preferred_location) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`, [fullName, email, phone, linkedinUrl, portfolioUrl, yearsOfExperience, currentPosition, currentCompany, preferredLocation] ); const applicantId = applicantResult.rows[0].id; // Insert application with CV await client.query( `INSERT INTO applications (applicant_id, job_id, cover_letter, cv_filename, cv_content_type, cv_file, status) VALUES ($1, $2, $3, $4, $5, $6, 'new')`, [ applicantId, jobId || null, coverLetter, req.file.originalname, req.file.mimetype, req.file.buffer ] ); await client.query('COMMIT'); res.json({ success: true, message: 'Application submitted successfully', applicantId }); } catch (err) { await client.query('ROLLBACK'); console.error('Error submitting application:', err); res.status(500).json({ error: 'Failed to submit application' }); } finally { client.release(); } }); // Submit contact form app.post('/api/contact', async (req, res) => { try { const { name, email, subject, message } = req.body; if (!name || !email || !message) { return res.status(400).json({ error: 'Name, email, and message are required' }); } await pool.query( 'INSERT INTO contact_submissions (name, email, subject, message) VALUES ($1, $2, $3, $4)', [name, email, subject, message] ); res.json({ success: true, message: 'Message sent successfully' }); } catch (err) { console.error('Error submitting contact form:', err); res.status(500).json({ error: 'Failed to send message' }); } }); // ============================================ // ADMIN AUTHENTICATION ENDPOINTS // ============================================ // Admin login (creates admin on first login if none exist) app.post('/api/admin/login', async (req, res) => { try { const { email, password, fullName } = req.body; if (!email || !password) { return res.status(400).json({ error: 'Email and password are required' }); } // Check if any admins exist const adminCount = await pool.query('SELECT COUNT(*) FROM admins'); const isFirstAdmin = adminCount.rows[0].count === '0'; if (isFirstAdmin) { // Create first admin if (!fullName) { return res.status(400).json({ error: 'Full name is required for first admin creation' }); } const hashedPassword = await bcrypt.hash(password, 10); const result = await pool.query( 'INSERT INTO admins (email, password_hash, full_name) VALUES ($1, $2, $3) RETURNING id, email, full_name', [email, hashedPassword, fullName] ); const admin = result.rows[0]; req.session.adminId = admin.id; req.session.adminEmail = admin.email; req.session.adminName = admin.full_name; return res.json({ success: true, message: 'Admin account created successfully', admin: { id: admin.id, email: admin.email, fullName: admin.full_name }, isFirstLogin: true }); } // Regular login const result = await pool.query( 'SELECT id, email, password_hash, full_name FROM admins WHERE email = $1', [email] ); if (result.rows.length === 0) { return res.status(401).json({ error: 'Invalid credentials' }); } const admin = result.rows[0]; const passwordMatch = await bcrypt.compare(password, admin.password_hash); if (!passwordMatch) { return res.status(401).json({ error: 'Invalid credentials' }); } // Update last login await pool.query('UPDATE admins SET last_login = CURRENT_TIMESTAMP WHERE id = $1', [admin.id]); req.session.adminId = admin.id; req.session.adminEmail = admin.email; req.session.adminName = admin.full_name; res.json({ success: true, admin: { id: admin.id, email: admin.email, fullName: admin.full_name } }); } catch (err) { console.error('Error during login:', err); res.status(500).json({ error: 'Login failed' }); } }); // Check if admin is logged in app.get('/api/admin/check', (req, res) => { if (req.session.adminId) { res.json({ loggedIn: true, admin: { id: req.session.adminId, email: req.session.adminEmail, fullName: req.session.adminName } }); } else { res.json({ loggedIn: false }); } }); // Check if first admin needs to be created app.get('/api/admin/check-first', async (req, res) => { try { const result = await pool.query('SELECT COUNT(*) FROM admins'); const isFirstAdmin = result.rows[0].count === '0'; res.json({ isFirstAdmin }); } catch (err) { console.error('Error checking first admin:', err); res.status(500).json({ error: 'Failed to check admin status' }); } }); // Admin logout app.post('/api/admin/logout', (req, res) => { req.session.destroy((err) => { if (err) { return res.status(500).json({ error: 'Logout failed' }); } res.json({ success: true }); }); }); // ============================================ // ADMIN API ENDPOINTS (Protected) // ============================================ // Get dashboard statistics app.get('/api/admin/stats', requireAuth, async (req, res) => { try { const stats = await pool.query(` SELECT (SELECT COUNT(*) FROM applications WHERE status = 'new') as new_applications, (SELECT COUNT(*) FROM applications) as total_applications, (SELECT COUNT(*) FROM applicants) as total_applicants, (SELECT COUNT(*) FROM job_postings WHERE is_active = true) as active_jobs, (SELECT COUNT(*) FROM contact_submissions WHERE is_read = false) as unread_messages `); res.json(stats.rows[0]); } catch (err) { console.error('Error fetching stats:', err); res.status(500).json({ error: 'Failed to fetch statistics' }); } }); // Get all applications with applicant details app.get('/api/admin/applications', requireAuth, async (req, res) => { try { const { status, jobId, search } = req.query; let query = ` SELECT app.id, app.status, app.applied_at, app.cv_filename, app.cover_letter, app.notes, applicant.id as applicant_id, applicant.full_name, applicant.email, applicant.phone, applicant.years_of_experience, applicant.current_position, applicant.current_company, job.id as job_id, job.title as job_title FROM applications app INNER JOIN applicants applicant ON app.applicant_id = applicant.id LEFT JOIN job_postings job ON app.job_id = job.id WHERE 1=1 `; const params = []; let paramCount = 0; if (status) { paramCount++; query += ` AND app.status = $${paramCount}`; params.push(status); } if (jobId) { paramCount++; query += ` AND app.job_id = $${paramCount}`; params.push(jobId); } if (search) { paramCount++; query += ` AND (applicant.full_name ILIKE $${paramCount} OR applicant.email ILIKE $${paramCount})`; params.push(`%${search}%`); } query += ' ORDER BY app.applied_at DESC'; const result = await pool.query(query, params); res.json(result.rows); } catch (err) { console.error('Error fetching applications:', err); res.status(500).json({ error: 'Failed to fetch applications' }); } }); // Get single application details app.get('/api/admin/applications/:id', requireAuth, async (req, res) => { try { const result = await pool.query(` SELECT app.*, applicant.full_name, applicant.email, applicant.phone, applicant.linkedin_url, applicant.portfolio_url, applicant.years_of_experience, applicant.current_position, applicant.current_company, applicant.preferred_location, job.title as job_title FROM applications app INNER JOIN applicants applicant ON app.applicant_id = applicant.id LEFT JOIN job_postings job ON app.job_id = job.id WHERE app.id = $1 `, [req.params.id]); if (result.rows.length === 0) { return res.status(404).json({ error: 'Application not found' }); } // Don't send the CV file in this response const application = result.rows[0]; delete application.cv_file; res.json(application); } catch (err) { console.error('Error fetching application:', err); res.status(500).json({ error: 'Failed to fetch application' }); } }); // Download CV app.get('/api/admin/applications/:id/cv', requireAuth, async (req, res) => { try { const result = await pool.query( 'SELECT cv_file, cv_filename, cv_content_type FROM applications WHERE id = $1', [req.params.id] ); if (result.rows.length === 0 || !result.rows[0].cv_file) { return res.status(404).json({ error: 'CV not found' }); } const { cv_file, cv_filename, cv_content_type } = result.rows[0]; res.setHeader('Content-Type', cv_content_type); res.setHeader('Content-Disposition', `attachment; filename="${cv_filename}"`); res.send(cv_file); } catch (err) { console.error('Error downloading CV:', err); res.status(500).json({ error: 'Failed to download CV' }); } }); // Update application status app.patch('/api/admin/applications/:id', requireAuth, async (req, res) => { try { const { status, notes } = req.body; const result = await pool.query( 'UPDATE applications SET status = COALESCE($1, status), notes = COALESCE($2, notes) WHERE id = $3 RETURNING *', [status, notes, req.params.id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Application not found' }); } res.json(result.rows[0]); } catch (err) { console.error('Error updating application:', err); res.status(500).json({ error: 'Failed to update application' }); } }); // Get all job postings (admin view - includes inactive) app.get('/api/admin/jobs', requireAuth, async (req, res) => { try { const result = await pool.query( 'SELECT * FROM job_postings ORDER BY created_at DESC' ); res.json(result.rows); } catch (err) { console.error('Error fetching jobs:', err); res.status(500).json({ error: 'Failed to fetch jobs' }); } }); // Create job posting app.post('/api/admin/jobs', requireAuth, async (req, res) => { try { const { title, department, location, employmentType, salaryRange, description, requirements, benefits } = req.body; if (!title || !description) { return res.status(400).json({ error: 'Title and description are required' }); } const result = await pool.query( `INSERT INTO job_postings (title, department, location, employment_type, salary_range, description, requirements, benefits, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [title, department, location, employmentType, salaryRange, description, requirements, benefits, req.session.adminId] ); res.json(result.rows[0]); } catch (err) { console.error('Error creating job:', err); res.status(500).json({ error: 'Failed to create job posting' }); } }); // Update job posting app.patch('/api/admin/jobs/:id', requireAuth, async (req, res) => { try { const { title, department, location, employmentType, salaryRange, description, requirements, benefits, isActive } = req.body; const result = await pool.query( `UPDATE job_postings SET title = COALESCE($1, title), department = COALESCE($2, department), location = COALESCE($3, location), employment_type = COALESCE($4, employment_type), salary_range = COALESCE($5, salary_range), description = COALESCE($6, description), requirements = COALESCE($7, requirements), benefits = COALESCE($8, benefits), is_active = COALESCE($9, is_active) WHERE id = $10 RETURNING *`, [title, department, location, employmentType, salaryRange, description, requirements, benefits, isActive, req.params.id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Job not found' }); } res.json(result.rows[0]); } catch (err) { console.error('Error updating job:', err); res.status(500).json({ error: 'Failed to update job posting' }); } }); // Delete job posting app.delete('/api/admin/jobs/:id', requireAuth, async (req, res) => { try { const result = await pool.query( 'DELETE FROM job_postings WHERE id = $1 RETURNING id', [req.params.id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Job not found' }); } res.json({ success: true, message: 'Job deleted successfully' }); } catch (err) { console.error('Error deleting job:', err); res.status(500).json({ error: 'Failed to delete job posting' }); } }); // Get contact submissions app.get('/api/admin/contacts', requireAuth, async (req, res) => { try { const result = await pool.query( 'SELECT * FROM contact_submissions ORDER BY created_at DESC' ); res.json(result.rows); } catch (err) { console.error('Error fetching contacts:', err); res.status(500).json({ error: 'Failed to fetch contact submissions' }); } }); // Mark contact as read app.patch('/api/admin/contacts/:id', requireAuth, async (req, res) => { try { const result = await pool.query( 'UPDATE contact_submissions SET is_read = true WHERE id = $1 RETURNING *', [req.params.id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Contact submission not found' }); } res.json(result.rows[0]); } catch (err) { console.error('Error updating contact:', err); res.status(500).json({ error: 'Failed to update contact submission' }); } }); // ============================================ // ERROR HANDLING // ============================================ app.use((err, req, res, next) => { if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(400).json({ error: 'File size too large. Maximum size is 5MB.' }); } return res.status(400).json({ error: err.message }); } console.error('Unhandled error:', err); res.status(500).json({ error: 'Internal server error' }); }); // ============================================ // START SERVER // ============================================ app.listen(PORT, '0.0.0.0', () => { console.log(`AI Recruitment Site running on port ${PORT}`); console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); console.log(`Database: ${process.env.DB_HOST || 'postgres'}:${process.env.DB_PORT || 5432}`); }); // Graceful shutdown process.on('SIGTERM', () => { console.log('SIGTERM signal received: closing HTTP server'); pool.end(() => { console.log('Database pool closed'); process.exit(0); }); });