Files
ai-recruit-site-template/server.js
2026-01-23 21:45:38 +01:00

680 lines
20 KiB
JavaScript

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 (non-blocking)
pool.query('SELECT NOW()')
.then((res) => {
console.log('Database connected successfully at:', res.rows[0].now);
})
.catch((err) => {
console.error('Database connection error:', err.message);
console.error('Application will continue but database operations will fail');
console.error('Please ensure PostgreSQL is running and accessible');
});
// 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
}
}));
// Serve static files first
app.use(express.static('public'));
// Middleware to handle routes without .html extension
app.use((req, res, next) => {
// Skip API routes
if (req.path.startsWith('/api/')) {
return next();
}
// Handle root path
if (req.path === '/') {
return res.sendFile(path.join(__dirname, 'public', 'index.html'));
}
// Handle paths without extensions (not already handled by static middleware)
if (req.path.indexOf('.') === -1) {
const file = `${req.path}.html`;
return res.sendFile(path.join(__dirname, 'public', file), (err) => {
if (err) {
next();
}
});
}
next();
});
// 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);
});
});