From 406d278a399a40aba252a85e70559701a9b51f6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20West=C3=B6=C3=B6?= Date: Fri, 23 Jan 2026 21:17:24 +0100 Subject: [PATCH] Initial commit: AI Recruitment Site for Ryans Recruit Firm - Complete PostgreSQL schema with migrations - Node.js/Express backend with authentication - Public website (home, about, services, jobs, apply, contact) - Admin dashboard with applicant and job management - CV upload and storage in PostgreSQL BYTEA - Docker Compose setup for deployment - Session-based authentication - Responsive design with Ryan brand colors --- .dockerignore | 11 + .env.example | 16 + .gitignore | 10 + Dockerfile | 29 ++ README.md | 368 ++++++++++++++++++ docker-compose.yml | 54 +++ migrations/001_init_schema.sql | 150 ++++++++ migrations/002_seed_data.sql | 113 ++++++ package.json | 26 ++ public/about.html | 177 +++++++++ public/admin/applicants.html | 82 ++++ public/admin/dashboard.html | 88 +++++ public/admin/jobs.html | 75 ++++ public/admin/login.html | 82 ++++ public/apply.html | 171 +++++++++ public/contact.html | 169 +++++++++ public/css/styles.css | 617 ++++++++++++++++++++++++++++++ public/index.html | 243 ++++++++++++ public/jobs.html | 92 +++++ public/js/admin.js | 290 ++++++++++++++ public/js/main.js | 201 ++++++++++ public/services.html | 109 ++++++ server.js | 669 +++++++++++++++++++++++++++++++++ 23 files changed, 3842 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 migrations/001_init_schema.sql create mode 100644 migrations/002_seed_data.sql create mode 100644 package.json create mode 100644 public/about.html create mode 100644 public/admin/applicants.html create mode 100644 public/admin/dashboard.html create mode 100644 public/admin/jobs.html create mode 100644 public/admin/login.html create mode 100644 public/apply.html create mode 100644 public/contact.html create mode 100644 public/css/styles.css create mode 100644 public/index.html create mode 100644 public/jobs.html create mode 100644 public/js/admin.js create mode 100644 public/js/main.js create mode 100644 public/services.html create mode 100644 server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2cd4075 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +npm-debug.log +.git +.gitignore +.env +.env.local +README.md +.DS_Store +*.log +.vscode +.idea diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..11628de --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Database Configuration +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=recruitment +DB_USER=postgres +DB_PASSWORD=changeme123 + +# Application Configuration +PORT=3000 +NODE_ENV=production + +# Session Secret (CHANGE THIS IN PRODUCTION!) +SESSION_SECRET=your-very-secret-session-key-change-this-in-production + +# Optional: Application URL +APP_URL=http://localhost:3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a502adf --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.env +.env.local +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store +*.log +.vscode/ +.idea/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6b33cc6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM node:18-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install --production + +# Copy application code +COPY server.js ./ +COPY public ./public +COPY migrations ./migrations + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 && \ + chown -R nodejs:nodejs /app + +USER nodejs + +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed91ecb --- /dev/null +++ b/README.md @@ -0,0 +1,368 @@ +# Ryans Recruit Firm - AI Recruitment Site + +A professional recruitment website built with Node.js, Express, and PostgreSQL. Features include job listings, application management, CV storage, and admin dashboard. + +## Features + +### Public Features +- **Job Listings** - Browse open positions with detailed information +- **Online Applications** - Submit applications with CV upload +- **Contact Form** - Get in touch with the recruitment team +- **Responsive Design** - Works on desktop, tablet, and mobile + +### Admin Features +- **Dashboard** - Overview statistics and quick access +- **Application Management** - View, filter, and manage applicants +- **CV Downloads** - Download applicant CVs directly +- **Job Management** - Create, edit, and manage job postings +- **First-time Setup** - Automatic admin account creation on first login + +## Technology Stack + +- **Backend**: Node.js + Express +- **Database**: PostgreSQL with BYTEA for CV storage +- **Authentication**: Session-based with bcrypt +- **File Upload**: Multer (PDF, DOC, DOCX support) +- **Deployment**: Docker + Docker Compose + Coolify + +## Quick Start + +### Local Development + +1. **Clone the repository** + ```bash + git clone + cd ai-recruit-site-template + ``` + +2. **Install dependencies** + ```bash + npm install + ``` + +3. **Set up environment** + ```bash + cp .env.example .env + # Edit .env with your configuration + ``` + +4. **Start PostgreSQL** (if not using Docker) + ```bash + # Install PostgreSQL and create database 'recruitment' + psql -U postgres -c "CREATE DATABASE recruitment;" + ``` + +5. **Run migrations** + ```bash + psql -U postgres -d recruitment -f migrations/001_init_schema.sql + psql -U postgres -d recruitment -f migrations/002_seed_data.sql + ``` + +6. **Start the application** + ```bash + npm start + ``` + +7. **Access the site** + - Public site: http://localhost:3000 + - Admin panel: http://localhost:3000/admin/login + +### Docker Development + +1. **Start all services** + ```bash + docker-compose up -d + ``` + +2. **View logs** + ```bash + docker-compose logs -f app + ``` + +3. **Stop services** + ```bash + docker-compose down + ``` + +## Deployment to Coolify + +### Prerequisites + +- Coolify account and API token +- Gitea repository +- Cloudflare account (for DNS) + +### Environment Variables + +```bash +export COOLIFY_TOKEN="your-coolify-api-token" +export GITEA_TOKEN="your-gitea-access-token" +export CLOUDFLARE_TOKEN="your-cloudflare-api-token" +export COOLIFY_API="https://app.coolify.io/api/v1" +export GITEA_API="https://git.startanaicompany.com/api/v1" +export CLOUDFLARE_API="https://api.cloudflare.com/client/v4" +``` + +### Step 1: Generate SSH Deploy Keys + +```bash +ssh-keygen -t ed25519 -f /tmp/recruitai_deploy_key -C "coolify-recruitai-deploy" -N "" +``` + +### Step 2: Create Coolify Project + +```bash +curl -s -X POST "${COOLIFY_API}/projects" \ + -H "Authorization: Bearer ${COOLIFY_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "RecruitAI", + "description": "AI Recruitment Site" + }' +``` + +### Step 3: Add Deploy Key to Coolify + +```bash +PRIVATE_KEY=$(cat /tmp/recruitai_deploy_key | jq -R -s .) + +curl -s -X POST "${COOLIFY_API}/private-keys" \ + -H "Authorization: Bearer ${COOLIFY_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"recruitai-deploy-key\", + \"description\": \"Deploy key for recruitai repository\", + \"private_key\": ${PRIVATE_KEY} + }" +``` + +### Step 4: Push to Gitea + +```bash +git remote add origin git@git.startanaicompany.com:username/ai-recruit-site-template.git +git add . +git commit -m "Initial commit: AI Recruitment Site" +git push -u origin main +``` + +### Step 5: Add Deploy Key to Gitea + +```bash +cat > /tmp/gitea_deploy_key.json << EOF +{ + "title": "Coolify Deploy Key", + "key": "$(cat /tmp/recruitai_deploy_key.pub)", + "read_only": true +} +EOF + +curl -s -X POST "${GITEA_API}/repos/username/ai-recruit-site-template/keys" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d @/tmp/gitea_deploy_key.json +``` + +### Step 6: Create Coolify Application + +```bash +curl -s -X POST "${COOLIFY_API}/applications/public" \ + -H "Authorization: Bearer ${COOLIFY_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "project_uuid": "YOUR_PROJECT_UUID", + "environment_name": "production", + "server_uuid": 0, + "type": "public", + "source": { + "type": "git", + "url": "git@git.startanaicompany.com:username/ai-recruit-site-template", + "branch": "main", + "private_key_uuid": "YOUR_PRIVATE_KEY_UUID" + }, + "build_pack": "dockerfile", + "ports_exposes": "3000", + "name": "recruitai" + }' +``` + +### Step 7: Add Webhook to Gitea + +```bash +curl -s -X POST "${GITEA_API}/repos/username/ai-recruit-site-template/hooks" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "gitea", + "config": { + "url": "https://app.coolify.io/api/v1/deploy?uuid=YOUR_APP_UUID", + "content_type": "json", + "http_method": "GET" + }, + "events": ["push"], + "active": true + }' +``` + +### Step 8: Configure Cloudflare DNS + +```bash +curl -s -X POST "${CLOUDFLARE_API}/zones/YOUR_ZONE_ID/dns_records" \ + -H "Authorization: Bearer ${CLOUDFLARE_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "CNAME", + "name": "recruitai", + "content": "YOUR_COOLIFY_FQDN", + "ttl": 1, + "proxied": false + }' +``` + +### Step 9: Update Application Domain + +```bash +curl -s -X PATCH "${COOLIFY_API}/applications/YOUR_APP_UUID" \ + -H "Authorization: Bearer ${COOLIFY_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "domains": "http://recruitai.startanaicompany.com" + }' +``` + +### Step 10: Deploy + +```bash +curl -s -X GET "${COOLIFY_API}/deploy?uuid=YOUR_APP_UUID&force=true" \ + -H "Authorization: Bearer ${COOLIFY_TOKEN}" +``` + +## First Time Setup + +### Admin Account + +1. Navigate to `/admin/login` +2. On first visit, you'll be prompted to create an admin account +3. Enter your email, password, and full name +4. Click "Create Admin Account" +5. You'll be logged in automatically + +### Add Jobs + +1. Go to Admin Dashboard → Jobs +2. Click "Create New Job" +3. Fill in job details +4. Click "Save" + +## Database Schema + +### Tables + +- **admins** - Admin user accounts +- **job_postings** - Job listings +- **applicants** - Applicant information +- **applications** - Application submissions with CV storage (BYTEA) +- **contact_submissions** - Contact form messages + +### Migrations + +Migrations are located in `/migrations/` and run automatically on first Docker startup. + +## API Endpoints + +### Public APIs + +- `GET /api/jobs` - List all active jobs +- `GET /api/jobs/:id` - Get job details +- `POST /api/apply` - Submit application (multipart/form-data) +- `POST /api/contact` - Submit contact form + +### Admin APIs (Authentication Required) + +- `POST /api/admin/login` - Login / Create first admin +- `GET /api/admin/check` - Check auth status +- `POST /api/admin/logout` - Logout +- `GET /api/admin/stats` - Dashboard statistics +- `GET /api/admin/applications` - List applications +- `GET /api/admin/applications/:id` - Get application details +- `GET /api/admin/applications/:id/cv` - Download CV +- `PATCH /api/admin/applications/:id` - Update application +- `GET /api/admin/jobs` - List all jobs +- `POST /api/admin/jobs` - Create job +- `PATCH /api/admin/jobs/:id` - Update job +- `DELETE /api/admin/jobs/:id` - Delete job + +## Security Features + +- **Password Hashing** - bcrypt with 10 rounds +- **Session Management** - Secure HTTP-only cookies +- **SQL Injection Protection** - Parameterized queries +- **File Upload Validation** - Type and size checks +- **CSRF Protection** - Session-based authentication +- **Rate Limiting** - (Recommended to add in production) + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `DB_HOST` | PostgreSQL host | `postgres` | +| `DB_PORT` | PostgreSQL port | `5432` | +| `DB_NAME` | Database name | `recruitment` | +| `DB_USER` | Database user | `postgres` | +| `DB_PASSWORD` | Database password | `changeme123` | +| `PORT` | Application port | `3000` | +| `NODE_ENV` | Environment | `production` | +| `SESSION_SECRET` | Session encryption key | *(required)* | + +## File Upload Limits + +- **Max CV Size**: 5MB +- **Allowed Types**: PDF, DOC, DOCX +- **Storage**: PostgreSQL BYTEA column + +## Troubleshooting + +### Database Connection Issues + +```bash +# Check if PostgreSQL is running +docker-compose ps + +# View PostgreSQL logs +docker-compose logs postgres + +# Restart PostgreSQL +docker-compose restart postgres +``` + +### Migration Errors + +```bash +# Connect to database +docker-compose exec postgres psql -U postgres -d recruitment + +# Check tables +\dt + +# Re-run migrations manually +docker-compose exec postgres psql -U postgres -d recruitment -f /docker-entrypoint-initdb.d/001_init_schema.sql +``` + +### Application Won't Start + +```bash +# Check application logs +docker-compose logs app + +# Rebuild and restart +docker-compose down +docker-compose build --no-cache +docker-compose up -d +``` + +## License + +MIT + +## Support + +For issues or questions, contact: info@ryansrecruit.com diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f6bc24d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + container_name: recruitment-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME:-recruitment} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme123} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./migrations:/docker-entrypoint-initdb.d + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - recruitment-network + + app: + build: . + container_name: recruitment-app + restart: unless-stopped + ports: + - "${PORT:-3000}:3000" + environment: + NODE_ENV: production + PORT: 3000 + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: ${DB_NAME:-recruitment} + DB_USER: ${DB_USER:-postgres} + DB_PASSWORD: ${DB_PASSWORD:-changeme123} + SESSION_SECRET: ${SESSION_SECRET:-change-this-secret-in-production} + depends_on: + postgres: + condition: service_healthy + networks: + - recruitment-network + volumes: + - ./public:/app/public + +volumes: + postgres_data: + driver: local + +networks: + recruitment-network: + driver: bridge diff --git a/migrations/001_init_schema.sql b/migrations/001_init_schema.sql new file mode 100644 index 0000000..455d5bf --- /dev/null +++ b/migrations/001_init_schema.sql @@ -0,0 +1,150 @@ +-- AI Recruitment Site Database Schema +-- Using PostgreSQL with proper indexing and constraints + +-- Admins table +CREATE TABLE IF NOT EXISTS admins ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + full_name VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP WITH TIME ZONE +); + +-- Create index for faster email lookups +CREATE INDEX IF NOT EXISTS idx_admins_email ON admins(email); + +-- Job postings table +CREATE TABLE IF NOT EXISTS job_postings ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + department VARCHAR(100), + location VARCHAR(255), + employment_type VARCHAR(50), -- Full-time, Part-time, Contract + salary_range VARCHAR(100), + description TEXT NOT NULL, + requirements TEXT, + benefits TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by INTEGER REFERENCES admins(id) +); + +-- Create index for active jobs +CREATE INDEX IF NOT EXISTS idx_job_postings_active ON job_postings(is_active, created_at DESC); + +-- Applicants table +CREATE TABLE IF NOT EXISTS applicants ( + id SERIAL PRIMARY KEY, + full_name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + phone VARCHAR(50), + linkedin_url VARCHAR(500), + portfolio_url VARCHAR(500), + years_of_experience INTEGER, + current_position VARCHAR(255), + current_company VARCHAR(255), + preferred_location VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create index for applicant searches +CREATE INDEX IF NOT EXISTS idx_applicants_email ON applicants(email); +CREATE INDEX IF NOT EXISTS idx_applicants_name ON applicants(full_name); +CREATE INDEX IF NOT EXISTS idx_applicants_created_at ON applicants(created_at DESC); + +-- Applications table (links applicants to jobs with CV) +CREATE TABLE IF NOT EXISTS applications ( + id SERIAL PRIMARY KEY, + applicant_id INTEGER NOT NULL REFERENCES applicants(id) ON DELETE CASCADE, + job_id INTEGER REFERENCES job_postings(id) ON DELETE SET NULL, + cover_letter TEXT, + cv_filename VARCHAR(500), + cv_content_type VARCHAR(100), + cv_file BYTEA, -- Store CV as binary data + status VARCHAR(50) DEFAULT 'new', -- new, reviewing, interview, rejected, hired + notes TEXT, + applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes for application queries +CREATE INDEX IF NOT EXISTS idx_applications_applicant ON applications(applicant_id); +CREATE INDEX IF NOT EXISTS idx_applications_job ON applications(job_id); +CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status); +CREATE INDEX IF NOT EXISTS idx_applications_date ON applications(applied_at DESC); + +-- Add check constraint for application status +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'applications_status_check' + ) THEN + ALTER TABLE applications ADD CONSTRAINT applications_status_check + CHECK (status IN ('new', 'reviewing', 'interview', 'rejected', 'hired')); + END IF; +END $$; + +-- Add check constraint for employment type +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'job_postings_employment_type_check' + ) THEN + ALTER TABLE job_postings ADD CONSTRAINT job_postings_employment_type_check + CHECK (employment_type IN ('Full-time', 'Part-time', 'Contract', 'Internship', 'Freelance')); + END IF; +END $$; + +-- Contact submissions table +CREATE TABLE IF NOT EXISTS contact_submissions ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + subject VARCHAR(500), + message TEXT NOT NULL, + is_read BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create index for contact submissions +CREATE INDEX IF NOT EXISTS idx_contact_submissions_date ON contact_submissions(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_contact_submissions_unread ON contact_submissions(is_read, created_at DESC); + +-- Function to update the updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Trigger for job_postings +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_job_postings_updated_at' + ) THEN + CREATE TRIGGER update_job_postings_updated_at + BEFORE UPDATE ON job_postings + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + +-- Trigger for applications +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_applications_updated_at' + ) THEN + CREATE TRIGGER update_applications_updated_at + BEFORE UPDATE ON applications + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; diff --git a/migrations/002_seed_data.sql b/migrations/002_seed_data.sql new file mode 100644 index 0000000..ba307af --- /dev/null +++ b/migrations/002_seed_data.sql @@ -0,0 +1,113 @@ +-- Seed data for AI Recruitment Site + +-- Insert sample job postings +INSERT INTO job_postings (title, department, location, employment_type, salary_range, description, requirements, benefits, is_active) +VALUES +( + 'Senior Full-Stack Developer', + 'Engineering', + 'Remote / San Francisco, CA', + 'Full-time', + '$120,000 - $180,000', + 'We are seeking an experienced Full-Stack Developer to join our dynamic team. You will be responsible for designing, developing, and maintaining web applications that serve thousands of users daily. This role offers an opportunity to work with cutting-edge technologies and contribute to meaningful projects.', + '• 5+ years of experience in full-stack development +• Proficiency in JavaScript/TypeScript, React, Node.js +• Experience with PostgreSQL or similar relational databases +• Strong understanding of RESTful APIs and microservices +• Experience with Docker and cloud platforms (AWS/GCP/Azure) +• Excellent problem-solving and communication skills', + '• Competitive salary and equity package +• Health, dental, and vision insurance +• 401(k) matching +• Flexible work arrangements +• Professional development budget +• Unlimited PTO', + true +), +( + 'DevOps Engineer', + 'Engineering', + 'New York, NY', + 'Full-time', + '$100,000 - $150,000', + 'Join our infrastructure team to build and maintain scalable, reliable systems. You will work on automation, monitoring, and deployment pipelines that power our applications.', + '• 3+ years of DevOps or Site Reliability Engineering experience +• Strong knowledge of Kubernetes, Docker, and container orchestration +• Experience with CI/CD tools (Jenkins, GitLab CI, GitHub Actions) +• Proficiency in scripting languages (Python, Bash) +• Experience with infrastructure as code (Terraform, Ansible) +• Understanding of cloud platforms and networking', + '• Competitive compensation +• Stock options +• Remote work flexibility +• Learning and development opportunities +• Team events and offsites +• Modern tech stack', + true +), +( + 'UI/UX Designer', + 'Design', + 'Los Angeles, CA', + 'Full-time', + '$90,000 - $130,000', + 'We are looking for a creative UI/UX Designer to craft beautiful and intuitive user experiences. You will work closely with product managers and engineers to bring ideas to life.', + '• 3+ years of UI/UX design experience +• Strong portfolio demonstrating web and mobile design +• Proficiency in Figma, Sketch, or Adobe XD +• Understanding of user-centered design principles +• Experience conducting user research and usability testing +• Knowledge of HTML/CSS is a plus', + '• Creative and collaborative work environment +• Health and wellness benefits +• Flexible schedule +• Latest design tools and equipment +• Conference and workshop budget +• Generous vacation policy', + true +), +( + 'Data Scientist', + 'Data & Analytics', + 'Remote', + 'Full-time', + '$110,000 - $160,000', + 'Help us unlock insights from data. As a Data Scientist, you will develop machine learning models, analyze complex datasets, and contribute to data-driven decision making.', + '• Master''s or PhD in Computer Science, Statistics, or related field +• 2+ years of experience in data science or machine learning +• Strong programming skills in Python (pandas, scikit-learn, TensorFlow) +• Experience with SQL and data warehousing +• Knowledge of statistical modeling and A/B testing +• Excellent communication skills to explain technical concepts', + '• Competitive salary +• Work with cutting-edge ML technologies +• Conference attendance opportunities +• Remote-first culture +• Comprehensive health benefits +• Equity compensation', + true +), +( + 'Marketing Manager', + 'Marketing', + 'Chicago, IL', + 'Full-time', + '$80,000 - $120,000', + 'Lead our marketing initiatives and help grow our brand. You will develop and execute marketing strategies, manage campaigns, and analyze performance metrics.', + '• 5+ years of marketing experience +• Proven track record in B2B or B2C marketing +• Experience with digital marketing channels (SEO, SEM, social media) +• Strong analytical skills and data-driven mindset +• Excellent written and verbal communication +• Experience with marketing automation tools', + '• Competitive salary and bonuses +• Health insurance +• Professional development +• Collaborative team environment +• Flexible work options +• Career growth opportunities', + true +); + +-- Note: Admin users are created on first login, no seed data needed +-- Note: Applicants and applications will be created through the application form diff --git a/package.json b/package.json new file mode 100644 index 0000000..732ecdf --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "ai-recruit-site", + "version": "1.0.0", + "description": "AI Recruitment Site for Ryans Recruit Firm", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "keywords": [ + "recruitment", + "jobs", + "hr", + "postgres" + ], + "author": "Ryans Recruit Firm", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "pg": "^8.11.3", + "bcrypt": "^5.1.1", + "express-session": "^1.17.3", + "multer": "^1.4.5-lts.1", + "cookie-parser": "^1.4.6" + } +} diff --git a/public/about.html b/public/about.html new file mode 100644 index 0000000..55cc741 --- /dev/null +++ b/public/about.html @@ -0,0 +1,177 @@ + + + + + + About Us - Ryans Recruit Firm + + + +
+ +
+ +
+
+

About Ryans Recruit Firm

+

Building careers and companies through exceptional talent placement

+
+
+ +
+
+

Our Story

+

Founded in 2015, Ryans Recruit Firm has grown to become one of the most trusted names in professional recruitment. What started as a small team with a vision has evolved into a comprehensive recruitment solution serving clients across multiple industries worldwide.

+ +

Our founder, Ryan Martinez, saw a gap in the recruitment industry: too many firms focused on filling positions quickly rather than finding the right match. With a background in HR and a passion for helping people find meaningful work, Ryan set out to create a different kind of recruitment firm—one that prioritizes quality, transparency, and long-term success.

+ +

Today, we're proud to have helped thousands of professionals find their dream jobs and assisted hundreds of companies in building exceptional teams.

+
+
+ +
+
+
+

Our Values

+

The principles that guide everything we do

+
+ +
+
+

🎯 Excellence

+

We maintain the highest standards in candidate screening, client service, and professional conduct. Mediocrity is not in our vocabulary.

+
+ +
+

🤝 Integrity

+

Honesty and transparency in all our dealings. We build trust through consistent, ethical behavior and open communication.

+
+ +
+

💡 Innovation

+

We leverage the latest technology and methodologies to improve our recruitment process and deliver better results.

+
+ +
+

❤️ Empathy

+

We understand that behind every resume is a person with dreams, and behind every job posting is a company with needs. We care about both.

+
+
+
+
+ +
+
+
+

By the Numbers

+

Our track record speaks for itself

+
+ +
+
+
5,000+
+
Successful Placements
+
+ +
+
500+
+
Partner Companies
+
+ +
+
95%
+
Client Satisfaction
+
+ +
+
14
+
Average Days to Placement
+
+
+
+
+ +
+
+
+

Our Mission

+
+ +
+

+ To revolutionize the recruitment industry by creating meaningful connections between talented professionals and forward-thinking companies, while maintaining the highest standards of integrity, transparency, and service excellence. +

+
+
+
+ +
+
+

Ready to Work With Us?

+

+ Whether you're looking for your next career move or seeking top talent for your company +

+
+ Browse Jobs + Contact Us +
+
+
+ +
+
+ + + +
+
+ + diff --git a/public/admin/applicants.html b/public/admin/applicants.html new file mode 100644 index 0000000..db3a0cd --- /dev/null +++ b/public/admin/applicants.html @@ -0,0 +1,82 @@ + + + + + + Applications - Ryans Recruit Admin + + + + +
+
+
+
R
+

Ryans Recruit Admin

+
+ +
+
+ + +
+

Applications Management

+

Review and manage all job applications

+
+ + +
+ +
+

Filters

+
+
+ + +
+
+
+ + + + + +
+ +
+ + +
+

No applications found

+
+
+ + + + + + diff --git a/public/admin/dashboard.html b/public/admin/dashboard.html new file mode 100644 index 0000000..3eeee9e --- /dev/null +++ b/public/admin/dashboard.html @@ -0,0 +1,88 @@ + + + + + + Admin Dashboard - Ryans Recruit + + + + +
+
+
+
R
+

Ryans Recruit Admin

+
+ +
+
+ + +
+

Admin Dashboard

+

Manage applications, jobs, and track recruitment metrics

+
+ + +
+ +
+ +
+

New Applications

+

0

+

This week

+
+ + +
+

Total Applications

+

0

+

All time

+
+ + +
+

Total Applicants

+

0

+

Unique users

+
+ + +
+

Active Jobs

+

0

+

Open positions

+
+
+ + +
+

Quick Access

+
+ +

📋

+

View Applications

+

Manage all applicants

+
+ +

💼

+

Manage Jobs

+

Create and edit positions

+
+
+
+
+ + + + diff --git a/public/admin/jobs.html b/public/admin/jobs.html new file mode 100644 index 0000000..04ecd32 --- /dev/null +++ b/public/admin/jobs.html @@ -0,0 +1,75 @@ + + + + + + Jobs - Ryans Recruit Admin + + + + +
+
+
+
R
+

Ryans Recruit Admin

+
+ +
+
+ + +
+

Job Postings Management

+

Create, edit, and manage job positions

+
+ + +
+ +
+

All Job Postings

+ +
+ + + + + +
+ +
+ + +
+

No job postings yet

+ +
+
+ + + + + + diff --git a/public/admin/login.html b/public/admin/login.html new file mode 100644 index 0000000..f0dc14a --- /dev/null +++ b/public/admin/login.html @@ -0,0 +1,82 @@ + + + + + + Admin Login - Ryans Recruit + + + +
+

Ryans Recruit

+

Admin Portal

+ + + + +
+ +
+ + +
+ + +
+ + +
+ + + + + + +
+ +

+ Back to Main Site +

+
+ + + + diff --git a/public/apply.html b/public/apply.html new file mode 100644 index 0000000..26c066f --- /dev/null +++ b/public/apply.html @@ -0,0 +1,171 @@ + + + + + + Apply for Job - Ryans Recruit Firm + + + +
+ +
+ +
+
+

Apply Now

+

Submit your application for consideration

+
+
+ +
+
+
+

Job Title

+
+ +
+
+ +
+

Application Form

+ +
+ + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
Accepted formats: PDF, DOC, DOCX (Max 5MB)
+
+ + +
+ +
+
+
+
+
+ +
+
+ + + +
+
+ + + + diff --git a/public/contact.html b/public/contact.html new file mode 100644 index 0000000..a327813 --- /dev/null +++ b/public/contact.html @@ -0,0 +1,169 @@ + + + + + + Contact Us - Ryans Recruit Firm + + + +
+ +
+ +
+
+

Contact Us

+

Get in touch with our team - we're here to help

+
+
+ +
+
+
+ +
+

Send us a Message

+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + +
+
+ + +
+

Get in Touch

+ +
+
+

+ 📧 Email +

+

+ info@ryansrecruit.com +

+
We typically respond within 24 hours
+
+ +
+

+ 📞 Phone +

+

+ +1 (555) 123-4567 +

+
Mon-Fri 9AM-6PM EST
+
+ +
+

+ 🏢 Office Hours +

+

+ Monday to Friday: 9:00 AM - 6:00 PM EST +

+

+ Saturday & Sunday: Closed +

+
+
+ +
+

Why Choose Us?

+
    +
  • ✓ Experienced recruitment team
  • +
  • ✓ Fast response times
  • +
  • ✓ Personalized support
  • +
  • ✓ Industry expertise
  • +
  • ✓ Proven track record
  • +
+
+
+
+
+
+ +
+
+ + + +
+
+ + + + diff --git a/public/css/styles.css b/public/css/styles.css new file mode 100644 index 0000000..c627b39 --- /dev/null +++ b/public/css/styles.css @@ -0,0 +1,617 @@ +/* AI Recruitment Site - Global Styles */ + +:root { + /* Ryan Brand Colors */ + --primary-color: #2563EB; /* Ryan Blue */ + --primary-dark: #1e40af; + --primary-light: #3b82f6; + --secondary-color: #1E293B; /* Dark */ + --accent-color: #059669; /* Ryan Green */ + --text-dark: #1E293B; + --text-light: #64748B; /* Gray */ + --bg-light: #F8FAFC; /* Light */ + --bg-white: #ffffff; + --border-color: #E2E8F0; /* Border */ + --success: #059669; /* Ryan Green */ + --warning: #F59E0B; /* Warning Orange */ + --error: #DC2626; /* Error Red */ + --info: #0EA5E9; /* Info Blue */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.15); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1); + /* Spacing System - 8px base */ + --spacing-1: 8px; + --spacing-2: 16px; + --spacing-3: 24px; + --spacing-4: 32px; + --spacing-5: 40px; + --spacing-6: 48px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: var(--text-dark); + background-color: var(--bg-light); +} + +/* Typography */ +h1, h2, h3, h4, h5, h6 { + font-weight: 700; + line-height: 1.2; + margin-bottom: 1rem; + color: var(--secondary-color); +} + +h1 { font-size: 2.5rem; } +h2 { font-size: 2rem; } +h3 { font-size: 1.75rem; } +h4 { font-size: 1.5rem; } +h5 { font-size: 1.25rem; } +h6 { font-size: 1rem; } + +p { + margin-bottom: 1rem; + color: var(--text-light); +} + +a { + color: var(--primary-color); + text-decoration: none; + transition: color 0.2s; +} + +a:hover { + color: var(--primary-dark); +} + +/* Container */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem; +} + +.container-narrow { + max-width: 900px; + margin: 0 auto; + padding: 0 2rem; +} + +/* Header & Navigation */ +header { + background: var(--bg-white); + box-shadow: var(--shadow-sm); + position: sticky; + top: 0; + z-index: 1000; +} + +nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 0; +} + +.logo { + font-size: 1.5rem; + font-weight: 700; + color: var(--primary-color); +} + +.nav-links { + display: flex; + list-style: none; + gap: 2rem; + align-items: center; +} + +.nav-links a { + color: var(--text-dark); + font-weight: 500; + transition: color 0.2s; +} + +.nav-links a:hover, +.nav-links a.active { + color: var(--primary-color); +} + +/* Hero Section */ +.hero { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + padding: 5rem 0; + text-align: center; +} + +.hero h1 { + color: white; + font-size: 3rem; + margin-bottom: 1rem; +} + +.hero p { + color: rgba(255, 255, 255, 0.9); + font-size: 1.25rem; + margin-bottom: 2rem; +} + +/* Buttons */ +.btn { + display: inline-block; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 0.5rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + text-align: center; +} + +.btn-primary { + background: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background: var(--primary-dark); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.btn-secondary { + background: var(--bg-white); + color: var(--primary-color); + border: 2px solid var(--primary-color); +} + +.btn-secondary:hover { + background: var(--primary-color); + color: white; +} + +.btn-success { + background: var(--success); + color: white; +} + +.btn-success:hover { + background: #059669; +} + +.btn-danger { + background: var(--error); + color: white; +} + +.btn-danger:hover { + background: #dc2626; +} + +.btn-sm { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.btn-lg { + padding: 1rem 2rem; + font-size: 1.125rem; +} + +/* Cards */ +.card { + background: var(--bg-white); + border-radius: 0.5rem; + padding: 2rem; + box-shadow: var(--shadow-md); + transition: transform 0.2s, box-shadow 0.2s; +} + +.card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); +} + +.card-header { + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 2px solid var(--border-color); +} + +.card-title { + font-size: 1.5rem; + color: var(--secondary-color); + margin-bottom: 0.5rem; +} + +.card-subtitle { + color: var(--text-light); + font-size: 0.875rem; +} + +/* Grid System */ +.grid { + display: grid; + gap: 2rem; +} + +.grid-2 { grid-template-columns: repeat(2, 1fr); } +.grid-3 { grid-template-columns: repeat(3, 1fr); } +.grid-4 { grid-template-columns: repeat(4, 1fr); } + +@media (max-width: 768px) { + .grid-2, .grid-3, .grid-4 { + grid-template-columns: 1fr; + } +} + +/* Sections */ +section { + padding: 4rem 0; +} + +.section-title { + text-align: center; + margin-bottom: 3rem; +} + +.section-title h2 { + font-size: 2.5rem; + margin-bottom: 0.5rem; +} + +.section-title p { + font-size: 1.125rem; + color: var(--text-light); +} + +/* Forms */ +.form-group { + margin-bottom: 1.5rem; +} + +label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: var(--text-dark); +} + +input[type="text"], +input[type="email"], +input[type="password"], +input[type="tel"], +input[type="url"], +input[type="number"], +input[type="file"], +select, +textarea { + width: 100%; + padding: 0.75rem; + border: 2px solid var(--border-color); + border-radius: 0.5rem; + font-size: 1rem; + transition: border-color 0.2s; + font-family: inherit; +} + +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--primary-color); +} + +textarea { + resize: vertical; + min-height: 120px; +} + +.form-error { + color: var(--error); + font-size: 0.875rem; + margin-top: 0.25rem; +} + +.form-help { + color: var(--text-light); + font-size: 0.875rem; + margin-top: 0.25rem; +} + +/* Job Listing */ +.job-card { + background: var(--bg-white); + border-radius: 0.5rem; + padding: 2rem; + box-shadow: var(--shadow-md); + margin-bottom: 1.5rem; + transition: transform 0.2s, box-shadow 0.2s; +} + +.job-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.job-header { + display: flex; + justify-content: space-between; + align-items: start; + margin-bottom: 1rem; +} + +.job-title { + font-size: 1.5rem; + color: var(--secondary-color); + margin-bottom: 0.5rem; +} + +.job-meta { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1rem; + font-size: 0.875rem; + color: var(--text-light); +} + +.job-meta-item { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.job-description { + color: var(--text-light); + margin-bottom: 1rem; + line-height: 1.6; +} + +.job-requirements { + margin-bottom: 1rem; +} + +.job-requirements h4 { + font-size: 1rem; + margin-bottom: 0.5rem; + color: var(--text-dark); +} + +.job-requirements ul { + padding-left: 1.5rem; + color: var(--text-light); +} + +.job-requirements li { + margin-bottom: 0.25rem; +} + +/* Tags/Badges */ +.tag { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.875rem; + font-weight: 600; +} + +.tag-primary { + background: rgba(37, 99, 235, 0.1); + color: var(--primary-color); +} + +.tag-success { + background: rgba(16, 185, 129, 0.1); + color: var(--success); +} + +.tag-warning { + background: rgba(245, 158, 11, 0.1); + color: var(--warning); +} + +.tag-error { + background: rgba(239, 68, 68, 0.1); + color: var(--error); +} + +/* Stats */ +.stat-card { + background: var(--bg-white); + border-radius: 0.5rem; + padding: 1.5rem; + box-shadow: var(--shadow-md); + text-align: center; +} + +.stat-value { + font-size: 2.5rem; + font-weight: 700; + color: var(--primary-color); + margin-bottom: 0.5rem; +} + +.stat-label { + color: var(--text-light); + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Tables */ +table { + width: 100%; + border-collapse: collapse; + background: var(--bg-white); + border-radius: 0.5rem; + overflow: hidden; + box-shadow: var(--shadow-md); +} + +thead { + background: var(--bg-light); +} + +th { + padding: 1rem; + text-align: left; + font-weight: 600; + color: var(--text-dark); + border-bottom: 2px solid var(--border-color); +} + +td { + padding: 1rem; + border-bottom: 1px solid var(--border-color); +} + +tr:last-child td { + border-bottom: none; +} + +tr:hover { + background: var(--bg-light); +} + +/* Alerts */ +.alert { + padding: 1rem; + border-radius: 0.5rem; + margin-bottom: 1rem; +} + +.alert-success { + background: rgba(16, 185, 129, 0.1); + color: var(--success); + border: 2px solid var(--success); +} + +.alert-error { + background: rgba(239, 68, 68, 0.1); + color: var(--error); + border: 2px solid var(--error); +} + +.alert-info { + background: rgba(37, 99, 235, 0.1); + color: var(--primary-color); + border: 2px solid var(--primary-color); +} + +/* Footer */ +footer { + background: var(--secondary-color); + color: white; + padding: 3rem 0 1rem; + margin-top: 4rem; +} + +.footer-content { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 2rem; + margin-bottom: 2rem; +} + +.footer-section h3 { + color: white; + margin-bottom: 1rem; +} + +.footer-section ul { + list-style: none; +} + +.footer-section li { + margin-bottom: 0.5rem; +} + +.footer-section a { + color: rgba(255, 255, 255, 0.8); +} + +.footer-section a:hover { + color: white; +} + +.footer-bottom { + text-align: center; + padding-top: 2rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.6); +} + +/* Loading */ +.loading { + display: inline-block; + width: 1rem; + height: 1rem; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: white; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Utilities */ +.text-center { text-align: center; } +.text-right { text-align: right; } +.text-left { text-align: left; } + +.mt-1 { margin-top: 0.5rem; } +.mt-2 { margin-top: 1rem; } +.mt-3 { margin-top: 1.5rem; } +.mt-4 { margin-top: 2rem; } + +.mb-1 { margin-bottom: 0.5rem; } +.mb-2 { margin-bottom: 1rem; } +.mb-3 { margin-bottom: 1.5rem; } +.mb-4 { margin-bottom: 2rem; } + +.hidden { display: none; } + +/* Responsive */ +@media (max-width: 768px) { + .container { + padding: 0 1rem; + } + + .hero h1 { + font-size: 2rem; + } + + .hero p { + font-size: 1rem; + } + + .nav-links { + gap: 1rem; + font-size: 0.875rem; + } + + h1 { font-size: 2rem; } + h2 { font-size: 1.75rem; } + h3 { font-size: 1.5rem; } + + .job-header { + flex-direction: column; + } + + .footer-content { + grid-template-columns: 1fr; + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..2d10b56 --- /dev/null +++ b/public/index.html @@ -0,0 +1,243 @@ + + + + + + Ryans Recruit Firm - Find Your Dream Job + + + +
+ +
+ +
+
+

Your Career Success Is Our Mission

+

Connecting talented professionals with leading companies worldwide

+
+ Browse Jobs + Get in Touch +
+
+
+ +
+
+
+

Why Choose Ryans Recruit Firm?

+

We're committed to finding the perfect match for both candidates and employers

+
+ +
+
+
🎯
+

Targeted Matching

+

Our advanced screening process ensures we match the right talent with the right opportunity, saving time and increasing success rates.

+
+ +
+
🌍
+

Global Network

+

Access to positions across multiple industries and locations, from startups to Fortune 500 companies worldwide.

+
+ +
+
🤝
+

Personal Support

+

Dedicated recruiters guide you through every step, from application to offer negotiation and beyond.

+
+ +
+
+

Fast Process

+

Streamlined recruitment process with quick turnaround times, getting you in front of hiring managers faster.

+
+ +
+
💼
+

Industry Experts

+

Our team has deep expertise across technology, finance, healthcare, and more, understanding what companies need.

+
+ +
+
🎓
+

Career Coaching

+

Free resume reviews, interview preparation, and career advice to help you land your dream role.

+
+
+
+
+ +
+
+
+

Our Success Stories

+

Hear from professionals who found their dream jobs through us

+
+ +
+
+

+ "Ryans Recruit Firm found me the perfect role in just two weeks. Their team understood exactly what I was looking for and matched me with a company that aligned with my career goals. Highly recommended!" +

+
+
SJ
+
+
Sarah Johnson
+
Senior Software Engineer
+
+
+
+ +
+

+ "The personalized support I received was incredible. From resume refinement to interview coaching, they were with me every step of the way. I'm now working at my dream company!" +

+
+
MC
+
+
Michael Chen
+
Product Manager
+
+
+
+ +
+

+ "As a hiring manager, I've worked with many recruiting firms, but Ryans stands out. They consistently deliver high-quality candidates who are well-screened and truly interested in our opportunities." +

+
+
EP
+
+
Emily Parker
+
HR Director
+
+
+
+ +
+

+ "After months of unsuccessful job hunting, Ryans helped me land a position that exceeded my salary expectations. Their expertise in negotiation made a huge difference!" +

+
+
DK
+
+
David Kim
+
Data Scientist
+
+
+
+
+
+
+ +
+
+
+

How It Works

+

Getting started is simple and straightforward

+
+ +
+
+
1
+

Browse Jobs

+

Explore our curated job listings across various industries and locations.

+
+ +
+
2
+

Submit Application

+

Apply with your resume and let us know what you're looking for.

+
+ +
+
3
+

Get Matched

+

Our team reviews your profile and matches you with suitable opportunities.

+
+ +
+
4
+

Start Your Journey

+

Receive support throughout interviews and land your dream job!

+
+
+ + +
+
+ +
+
+

Ready to Take the Next Step?

+

+ Join thousands of professionals who have found their dream jobs through Ryans Recruit Firm +

+
+ Browse Jobs + Contact Us +
+
+
+ +
+
+ + + +
+
+ + diff --git a/public/jobs.html b/public/jobs.html new file mode 100644 index 0000000..eb6a5c0 --- /dev/null +++ b/public/jobs.html @@ -0,0 +1,92 @@ + + + + + + Job Listings - Ryans Recruit Firm + + + +
+ +
+ +
+
+

Open Positions

+

Discover your next opportunity with our curated job listings

+
+
+ +
+
+
+
+

Loading job listings...

+
+ +
+ +
+
+
+ +
+
+ + + +
+
+ + + + diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..bd6e744 --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,290 @@ +// Admin JavaScript for Ryans Recruit Firm + +// Check authentication on all admin pages except login +if (!window.location.pathname.includes('login.html')) { + checkAuth(); +} + +async function checkAuth() { + try { + const response = await fetch('/api/admin/check'); + const data = await response.json(); + + if (!data.loggedIn) { + window.location.href = '/admin/login.html'; + return; + } + + // Update admin name in header if exists + const adminNameEl = document.getElementById('admin-name'); + if (adminNameEl && data.admin) { + adminNameEl.textContent = data.admin.fullName; + } + } catch (err) { + console.error('Auth check failed:', err); + window.location.href = '/admin/login.html'; + } +} + +// Login page handlers +if (window.location.pathname.includes('login.html')) { + checkFirstAdmin(); + document.getElementById('login-form')?.addEventListener('submit', handleLogin); +} + +async function checkFirstAdmin() { + try { + const response = await fetch('/api/admin/check-first'); + const data = await response.json(); + + if (data.isFirstAdmin) { + document.getElementById('first-admin-notice').style.display = 'block'; + document.getElementById('full-name-group').style.display = 'block'; + document.querySelector('button[type="submit"]').textContent = 'Create Admin Account'; + } + } catch (err) { + console.error('Error checking first admin:', err); + } +} + +async function handleLogin(e) { + e.preventDefault(); + + const form = e.target; + const submitBtn = form.querySelector('button[type="submit"]'); + const originalText = submitBtn.textContent; + + submitBtn.disabled = true; + submitBtn.innerHTML = ' Processing...'; + + const formData = new FormData(form); + const data = Object.fromEntries(formData); + + try { + const response = await fetch('/api/admin/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (response.ok) { + showAlert('Login successful!', 'success'); + setTimeout(() => window.location.href = '/admin/dashboard.html', 500); + } else { + showAlert(result.error || 'Login failed', 'error'); + } + } catch (err) { + console.error('Login error:', err); + showAlert('Login failed. Please try again.', 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = originalText; + } +} + +// Dashboard page +if (window.location.pathname.includes('dashboard.html')) { + loadDashboardStats(); +} + +async function loadDashboardStats() { + try { + const response = await fetch('/api/admin/stats'); + const stats = await response.json(); + + document.getElementById('stat-new-applications').textContent = stats.new_applications || 0; + document.getElementById('stat-total-applications').textContent = stats.total_applications || 0; + document.getElementById('stat-total-applicants').textContent = stats.total_applicants || 0; + document.getElementById('stat-active-jobs').textContent = stats.active_jobs || 0; + + if (stats.unread_messages > 0) { + document.getElementById('stat-unread-messages').textContent = stats.unread_messages; + document.getElementById('stat-unread-messages').parentElement.style.display = 'block'; + } + } catch (err) { + console.error('Error loading stats:', err); + } +} + +// Applications page +if (window.location.pathname.includes('applicants.html')) { + loadApplications(); +} + +async function loadApplications(filters = {}) { + const container = document.getElementById('applications-container'); + const loading = document.getElementById('loading'); + + try { + const params = new URLSearchParams(filters); + const response = await fetch(`/api/admin/applications?${params}`); + const applications = await response.json(); + + if (loading) loading.style.display = 'none'; + + if (applications.length === 0) { + container.innerHTML = '

No applications found.

'; + return; + } + + container.innerHTML = ` + + + + + + + + + + + + + ${applications.map(app => ` + + + + + + + + + `).join('')} + +
ApplicantJobExperienceAppliedStatusActions
+ ${escapeHtml(app.full_name)}
+ ${escapeHtml(app.email)} +
${escapeHtml(app.job_title || 'General Application')}${app.years_of_experience || 'N/A'} years${new Date(app.applied_at).toLocaleDateString()}${escapeHtml(app.status)} + View + CV +
+ `; + } catch (err) { + console.error('Error loading applications:', err); + if (loading) loading.style.display = 'none'; + container.innerHTML = '
Failed to load applications
'; + } +} + +// Jobs management page +if (window.location.pathname.includes('jobs.html') && window.location.pathname.includes('admin')) { + loadAdminJobs(); +} + +async function loadAdminJobs() { + const container = document.getElementById('jobs-container'); + const loading = document.getElementById('loading'); + + try { + const response = await fetch('/api/admin/jobs'); + const jobs = await response.json(); + + if (loading) loading.style.display = 'none'; + + container.innerHTML = ` +
+ +
+ + + + + + + + + + + + + ${jobs.map(job => ` + + + + + + + + + `).join('')} + +
TitleDepartmentLocationStatusCreatedActions
${escapeHtml(job.title)}${escapeHtml(job.department || 'N/A')}${escapeHtml(job.location || 'N/A')}${job.is_active ? 'Active' : 'Inactive'}${new Date(job.created_at).toLocaleDateString()} + + +
+ `; + } catch (err) { + console.error('Error loading jobs:', err); + if (loading) loading.style.display = 'none'; + container.innerHTML = '
Failed to load jobs
'; + } +} + +async function toggleJobStatus(jobId, isActive) { + try { + const response = await fetch(`/api/admin/jobs/${jobId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive }) + }); + + if (response.ok) { + showAlert(`Job ${isActive ? 'activated' : 'deactivated'} successfully`, 'success'); + loadAdminJobs(); + } else { + showAlert('Failed to update job status', 'error'); + } + } catch (err) { + console.error('Error updating job:', err); + showAlert('Failed to update job status', 'error'); + } +} + +// Logout +async function logout() { + try { + await fetch('/api/admin/logout', { method: 'POST' }); + window.location.href = '/admin/login.html'; + } catch (err) { + console.error('Logout error:', err); + window.location.href = '/admin/login.html'; + } +} + +// Utility functions +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function getStatusColor(status) { + const colors = { + 'new': 'primary', + 'reviewing': 'warning', + 'interview': 'info', + 'hired': 'success', + 'rejected': 'error' + }; + return colors[status] || 'primary'; +} + +function showAlert(message, type = 'info') { + const alertDiv = document.createElement('div'); + alertDiv.className = `alert alert-${type}`; + alertDiv.textContent = message; + alertDiv.style.position = 'fixed'; + alertDiv.style.top = '20px'; + alertDiv.style.right = '20px'; + alertDiv.style.zIndex = '10000'; + alertDiv.style.minWidth = '300px'; + + document.body.appendChild(alertDiv); + + setTimeout(() => { + alertDiv.remove(); + }, 5000); +} diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 0000000..ed65ee5 --- /dev/null +++ b/public/js/main.js @@ -0,0 +1,201 @@ +// Main JavaScript for Ryans Recruit Firm + +// Load jobs on jobs.html page +if (window.location.pathname.includes('jobs.html')) { + loadJobs(); +} + +async function loadJobs() { + const jobsContainer = document.getElementById('jobs-container'); + const loadingEl = document.getElementById('loading'); + + try { + const response = await fetch('/api/jobs'); + const jobs = await response.json(); + + if (loadingEl) loadingEl.style.display = 'none'; + + if (jobs.length === 0) { + jobsContainer.innerHTML = '

No job openings at the moment. Check back soon!

'; + return; + } + + jobsContainer.innerHTML = jobs.map(job => ` +
+
+
+

${escapeHtml(job.title)}

+
+ 📍 ${escapeHtml(job.location || 'Not specified')} + 💼 ${escapeHtml(job.employment_type || 'Full-time')} + ${job.salary_range ? `💰 ${escapeHtml(job.salary_range)}` : ''} + ${job.department ? `${escapeHtml(job.department)}` : ''} +
+
+
+

${escapeHtml(job.description.substring(0, 200))}...

+ Apply Now +
+ `).join(''); + } catch (err) { + console.error('Error loading jobs:', err); + if (loadingEl) loadingEl.style.display = 'none'; + jobsContainer.innerHTML = '
Failed to load job listings. Please try again later.
'; + } +} + +// Handle application form on apply.html +if (window.location.pathname.includes('apply.html')) { + loadJobDetails(); + document.getElementById('application-form')?.addEventListener('submit', handleApplicationSubmit); +} + +async function loadJobDetails() { + const urlParams = new URLSearchParams(window.location.search); + const jobId = urlParams.get('job'); + + if (!jobId) return; + + try { + const response = await fetch(`/api/jobs/${jobId}`); + const job = await response.json(); + + document.getElementById('job-title').textContent = job.title; + document.getElementById('job-details').innerHTML = ` +

Location: ${escapeHtml(job.location)}

+

Type: ${escapeHtml(job.employment_type)}

+ ${job.salary_range ? `

Salary: ${escapeHtml(job.salary_range)}

` : ''} +

Description:

+

${escapeHtml(job.description)}

+ `; + } catch (err) { + console.error('Error loading job details:', err); + } +} + +async function handleApplicationSubmit(e) { + e.preventDefault(); + + const form = e.target; + const submitBtn = form.querySelector('button[type="submit"]'); + const originalBtnText = submitBtn.textContent; + + submitBtn.disabled = true; + submitBtn.innerHTML = ' Submitting...'; + + const formData = new FormData(form); + + try { + const response = await fetch('/api/apply', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (response.ok) { + showAlert('Application submitted successfully! We\'ll be in touch soon.', 'success'); + form.reset(); + setTimeout(() => window.location.href = '/jobs.html', 2000); + } else { + showAlert(result.error || 'Failed to submit application', 'error'); + } + } catch (err) { + console.error('Error submitting application:', err); + showAlert('Failed to submit application. Please try again.', 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = originalBtnText; + } +} + +// Handle contact form on contact.html +if (window.location.pathname.includes('contact.html')) { + document.getElementById('contact-form')?.addEventListener('submit', handleContactSubmit); +} + +async function handleContactSubmit(e) { + e.preventDefault(); + + const form = e.target; + const submitBtn = form.querySelector('button[type="submit"]'); + const originalBtnText = submitBtn.textContent; + + submitBtn.disabled = true; + submitBtn.innerHTML = ' Sending...'; + + const formData = new FormData(form); + const data = Object.fromEntries(formData); + + try { + const response = await fetch('/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (response.ok) { + showAlert('Message sent successfully! We\'ll get back to you soon.', 'success'); + form.reset(); + } else { + showAlert(result.error || 'Failed to send message', 'error'); + } + } catch (err) { + console.error('Error sending message:', err); + showAlert('Failed to send message. Please try again.', 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = originalBtnText; + } +} + +// Utility functions +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function showAlert(message, type = 'info') { + const alertDiv = document.createElement('div'); + alertDiv.className = `alert alert-${type}`; + alertDiv.textContent = message; + alertDiv.style.position = 'fixed'; + alertDiv.style.top = '20px'; + alertDiv.style.right = '20px'; + alertDiv.style.zIndex = '10000'; + alertDiv.style.minWidth = '300px'; + + document.body.appendChild(alertDiv); + + setTimeout(() => { + alertDiv.remove(); + }, 5000); +} + +// File input validation +document.addEventListener('DOMContentLoaded', () => { + const fileInputs = document.querySelectorAll('input[type="file"]'); + fileInputs.forEach(input => { + input.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (file) { + const maxSize = 5 * 1024 * 1024; // 5MB + if (file.size > maxSize) { + showAlert('File size must be less than 5MB', 'error'); + e.target.value = ''; + return; + } + + const allowedTypes = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']; + if (!allowedTypes.includes(file.type)) { + showAlert('Only PDF, DOC, and DOCX files are allowed', 'error'); + e.target.value = ''; + return; + } + } + }); + }); +}); diff --git a/public/services.html b/public/services.html new file mode 100644 index 0000000..e99b7e6 --- /dev/null +++ b/public/services.html @@ -0,0 +1,109 @@ + + + + + + Our Services - Ryans Recruit Firm + + + +
+ +
+ +
+
+

Our Services

+

Comprehensive recruitment solutions tailored to your needs

+
+
+ +
+
+
+
+

For Job Seekers

+
    +
  • Career counseling and guidance
  • +
  • Resume review and optimization
  • +
  • Interview preparation and coaching
  • +
  • Salary negotiation support
  • +
  • Access to exclusive job opportunities
  • +
  • Long-term career development advice
  • +
+
+ +
+

For Employers

+
    +
  • Talent sourcing and screening
  • +
  • Comprehensive candidate assessment
  • +
  • Interview coordination
  • +
  • Background verification
  • +
  • Offer negotiation assistance
  • +
  • Post-placement follow-up
  • +
+
+
+
+
+ +
+
+
+

Industries We Serve

+
+
+

Technology

Software, IT, Engineering

+

Finance

Banking, Insurance, Investment

+

Healthcare

Medical, Pharmaceutical, Biotech

+

Marketing

Digital, Traditional, Analytics

+

Sales

B2B, B2C, Enterprise

+

Operations

Supply Chain, Logistics, Management

+
+
+
+ + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..0d81e6b --- /dev/null +++ b/server.js @@ -0,0 +1,669 @@ +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); + }); +});