Integrate complete Candidat platform with ASP.NET chatbot service

- Added Next.js frontend for candidate interviews
- Added Node.js backend with TypeScript and AI integration
- Added ASP.NET Core chatbot service for specialized AI conversations
- Added MySQL database with complete schema
- Added Nginx reverse proxy configuration
- Complete Docker Compose orchestration for all services
- Environment configuration for production, development, and Cloudflare
- Comprehensive documentation and setup instructions
- Flattened nested folder structures for clean organization
- Integrated chatbot service with fallback to direct AI calls
This commit is contained in:
Nixon 2025-09-20 10:45:21 +02:00
parent bcd25503c5
commit ec8342b5e2
128 changed files with 27581 additions and 10 deletions

153
TODO.md Normal file
View File

@ -0,0 +1,153 @@
# System Integration TODO - Merging ASP.NET Chatbot with Existing Backend
## Overview
Integrate the ASP.NET chatbot service into the existing Node.js backend system to provide specialized interview capabilities while maintaining the current architecture.
## Phase 1: Container Integration
### 1.1 Add Chatbot Service to Docker Compose
- **Status**: ✅ Completed
- **Description**: Add the ASP.NET chatbot service to the main docker-compose.yml file
- **Files**: `docker-compose.yml`
- **Details**: Configure service with proper networking, environment variables, and dependencies
### 1.2 Update Backend Docker Compose
- **Status**: ✅ Completed
- **Description**: Update backend docker-compose.yml to include chatbot service dependency
- **Files**: `backend/docker-compose.yml`
- **Details**: Add chatbot service and ensure proper service communication
### 1.3 Create Chatbot Service Dockerfile
- **Status**: ✅ Completed
- **Description**: Create proper Dockerfile for the ASP.NET chatbot service
- **Files**: `tuna/tuna/Dockerfile`
- **Details**: Multi-stage build with proper .NET 9.0 runtime and configuration
## Phase 2: Backend Service Integration
### 2.1 Create ChatbotService
- **Status**: ✅ Completed
- **Description**: Create new service to handle communication with ASP.NET chatbot
- **Files**: `backend/src/services/ChatbotService.ts`
- **Details**: HTTP client wrapper for chatbot service with error handling and fallback
### 2.2 Update AIService to Use Chatbot
- **Status**: ✅ Completed
- **Description**: Modify AIService to proxy requests to chatbot service instead of direct OpenRouter
- **Files**: `backend/src/services/AIService.ts`
- **Details**: Add chatbot service integration while maintaining fallback to direct OpenRouter
### 2.3 Update AIController
- **Status**: ✅ Completed
- **Description**: Modify AIController to use new chatbot service integration
- **Files**: `backend/src/controllers/rest/AIController.ts`
- **Details**: Update chat endpoints to use chatbot service, maintain existing interview flow
### 2.4 Add Environment Variables
- **Status**: ✅ Completed
- **Description**: Add new environment variables for chatbot service configuration
- **Files**: `env.example`, `env.production`, `env.cloudflare`
- **Details**: Add chatbot service URL, timeout, and fallback configuration
## Phase 3: ASP.NET Service Modifications
### 3.1 Add MySQL Database Support
- **Status**: ✅ Completed
- **Description**: Replace SQLite with MySQL database connection in ASP.NET service
- **Files**: `tuna/tuna/AISApp/AIS.cs`, `tuna/tuna/AISApp/Program.cs`
- **Details**: Add MySQL connection string and update database operations
### 3.2 Add Interview Context Endpoints
- **Status**: ✅ Completed
- **Description**: Create endpoints for interview initialization and context management
- **Files**: `tuna/tuna/AISApp/Program.cs`
- **Details**: Add endpoints for interview start, status, and completion
### 3.3 Implement Conversation Sync
- **Status**: ✅ Completed
- **Description**: Sync conversation data between ASP.NET service and MySQL database
- **Files**: `tuna/tuna/AISApp/AIS.cs`
- **Details**: Update conversation persistence to use MySQL instead of SQLite
### 3.4 Add Interview-Specific Prompts
- **Status**: ✅ Completed
- **Description**: Modify system prompts based on job requirements and interview context
- **Files**: `tuna/tuna/AISApp/prompt.txt`, `tuna/tuna/AISApp/AIS.cs`
- **Details**: Dynamic prompt generation based on job details and interview stage
## Phase 4: Database Integration
### 4.1 Update Database Schema
- **Status**: ✅ Completed
- **Description**: Add any required database changes for chatbot integration
- **Files**: `database/` (if needed)
- **Details**: Ensure conversation tables support chatbot service requirements
### 4.2 Add Database Migration Scripts
- **Status**: ✅ Completed
- **Description**: Create migration scripts for any database schema changes
- **Files**: `backend/` (new migration files)
- **Details**: SQL scripts for any required table modifications
## Phase 5: Configuration and Environment
### 5.1 Update Nginx Configuration
- **Status**: ✅ Completed (Not Required)
- **Description**: Add nginx routing for chatbot service if needed
- **Files**: `nginx/nginx.conf`
- **Details**: Add proxy rules for chatbot service endpoints
### 5.2 Update Environment Files
- **Status**: ✅ Completed
- **Description**: Update all environment files with chatbot service configuration
- **Files**: `env.example`, `env.production`, `env.cloudflare`
- **Details**: Add chatbot service environment variables
### 5.3 Update Docker Compose Environment
- **Status**: ✅ Completed
- **Description**: Add chatbot service environment variables to docker-compose
- **Files**: `docker-compose.yml`
- **Details**: Add environment variable mapping for chatbot service
## Phase 6: Testing and Validation
### 6.1 Service Communication Test
- **Status**: Pending
- **Description**: Test communication between backend and chatbot service
- **Details**: Verify HTTP requests work correctly between services
### 6.2 Database Integration Test
- **Status**: Pending
- **Description**: Test database operations in chatbot service
- **Details**: Verify conversation sync and data persistence
### 6.3 End-to-End Interview Flow Test
- **Status**: Pending
- **Description**: Test complete interview flow with chatbot integration
- **Details**: Verify mandatory questions → chat → completion flow works
## Phase 7: Documentation and Cleanup
### 7.1 Update API Documentation
- **Status**: Pending
- **Description**: Update API documentation to reflect chatbot integration
- **Files**: `backend/ADMIN_API.md`, `backend/AI_CONFIGURATION.md`
- **Details**: Document new chatbot service endpoints and configuration
### 7.2 Update Deployment Scripts
- **Status**: Pending
- **Description**: Update deployment scripts to include chatbot service
- **Files**: `deploy-production.ps1`, `deploy-production.sh`
- **Details**: Add chatbot service to deployment process
### 7.3 Clean Up Temporary Files
- **Status**: Pending
- **Description**: Remove any temporary files created during integration
- **Details**: Clean up test files and temporary configurations
## Notes
- All changes maintain backward compatibility
- Fallback to direct OpenRouter if chatbot service fails
- No frontend changes required initially
- Maintain existing interview flow and database structure
- Chatbot service runs on port 5000 internally, exposed via nginx if needed

9
backend/.barrels.json Normal file
View File

@ -0,0 +1,9 @@
{
"directory": ["./src/controllers/rest","./src/controllers/pages"],
"exclude": [
"**/__mock__",
"**/__mocks__",
"**/*.spec.ts"
],
"delete": true
}

14
backend/.dockerignore Normal file
View File

@ -0,0 +1,14 @@
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.nyc_output
coverage
.DS_Store
*.log

61
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,61 @@
### Node template
.DS_Store
# Logs
logs
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
node_modules
.npmrc
*.log
# Typings
typings/
# Typescript
/**/*.js
/**/*.js.map
test/**/*.js
test/**/*.js.map
# Test
/.tmp
/.nyc_output
# IDE
.vscode
.idea
# Project
/public
/dist
# Environment variables
.env
.env.local
.env.development
.env.production
.env.test
.env.*.local

21
backend/.swcrc Normal file
View File

@ -0,0 +1,21 @@
{
"sourceMaps": true,
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"target": "es2022",
"externalHelpers": true,
"keepClassNames": true,
"transform": {
"useDefineForClassFields": false,
"legacyDecorator": true,
"decoratorMetadata": true
}
},
"module": {
"type": "es6"
}
}

306
backend/ADMIN_API.md Normal file
View File

@ -0,0 +1,306 @@
# Admin API Endpoints
This document describes the admin-specific API endpoints for the Candivista platform.
## Authentication
All admin endpoints require authentication with a valid JWT token from a user with `role: 'admin'`.
**Headers:**
```
Authorization: Bearer <jwt_token>
Content-Type: application/json
```
## Base URL
```
http://localhost:8083/rest/admin
```
## Endpoints
### System Statistics
#### GET /statistics
Get system-wide statistics and metrics.
**Response:**
```json
{
"total_users": 150,
"active_users": 142,
"total_jobs": 89,
"total_interviews": 234,
"total_tokens_purchased": 1250,
"total_tokens_used": 890,
"total_revenue": 12500.00,
"generated_at": "2024-01-15T10:30:00Z"
}
```
### User Management
#### GET /users
Get all users in the system.
**Response:**
```json
[
{
"id": "user-uuid",
"email": "user@example.com",
"first_name": "John",
"last_name": "Doe",
"role": "recruiter",
"company_name": "Tech Corp",
"is_active": true,
"last_login_at": "2024-01-15T09:00:00Z",
"created_at": "2024-01-01T00:00:00Z"
}
]
```
#### GET /users/:id
Get a specific user by ID.
#### PUT /users/:id
Update user information.
**Request Body:**
```json
{
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"role": "recruiter",
"company_name": "Tech Corp",
"is_active": true
}
```
#### PATCH /users/:id/toggle-status
Toggle user active/inactive status.
**Response:**
```json
{
"success": true,
"new_status": false
}
```
#### PATCH /users/:id/password
Change user password.
**Request Body:**
```json
{
"new_password": "newpassword123"
}
```
#### POST /users
Create a new user.
**Request Body:**
```json
{
"email": "newuser@example.com",
"password": "password123",
"first_name": "Jane",
"last_name": "Smith",
"role": "recruiter",
"company_name": "Startup Inc"
}
```
### Job Management
#### GET /jobs
Get all jobs in the system with user information.
**Response:**
```json
[
{
"id": "job-uuid",
"user_id": "user-uuid",
"title": "Senior Developer",
"description": "Job description...",
"status": "active",
"created_at": "2024-01-15T10:00:00Z",
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"company_name": "Tech Corp"
}
]
```
#### GET /jobs/:id
Get a specific job by ID.
#### PATCH /jobs/:id/status
Update job status.
**Request Body:**
```json
{
"status": "paused"
}
```
#### PUT /jobs/:id
Update job information.
**Request Body:**
```json
{
"title": "Updated Job Title",
"description": "Updated description...",
"status": "active"
}
```
### Token Management
#### GET /user-token-summaries
Get token usage summaries for all users.
**Response:**
```json
[
{
"user_id": "user-uuid",
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"total_purchased": 50,
"total_used": 25,
"total_available": 25,
"utilization_percentage": 50.0
}
]
```
#### POST /add-tokens
Add tokens to a specific user.
**Request Body:**
```json
{
"user_id": "user-uuid",
"quantity": 10,
"price_per_token": 5.00,
"total_price": 50.00
}
```
### Token Packages
#### GET /token-packages
Get all token packages.
**Response:**
```json
[
{
"id": "package-uuid",
"name": "Professional Pack",
"description": "Ideal for regular recruiters",
"quantity": 20,
"price_per_token": 4.00,
"total_price": 80.00,
"discount_percentage": 20,
"is_popular": true,
"is_active": true
}
]
```
#### POST /token-packages
Create a new token package.
**Request Body:**
```json
{
"name": "New Package",
"description": "Package description",
"quantity": 10,
"price_per_token": 4.50,
"total_price": 45.00,
"discount_percentage": 10,
"is_popular": false,
"is_active": true
}
```
#### PUT /token-packages/:id
Update a token package.
#### PATCH /token-packages/:id/toggle-status
Toggle package active/inactive status.
#### DELETE /token-packages/:id
Delete a token package.
### Interview Management
#### GET /interviews
Get all interviews in the system.
#### GET /interviews/:id
Get a specific interview by ID.
### Payment Records
#### GET /payments
Get all payment records.
#### GET /payments/:id
Get a specific payment record by ID.
## Error Responses
All endpoints return appropriate HTTP status codes and error messages:
- `400 Bad Request` - Invalid request data
- `401 Unauthorized` - Invalid or missing authentication
- `403 Forbidden` - Insufficient permissions (non-admin user)
- `404 Not Found` - Resource not found
- `500 Internal Server Error` - Server error
**Error Response Format:**
```json
{
"message": "Error description",
"status": 400
}
```
## Testing
Use the provided test script to verify admin endpoints:
```bash
node test-admin.js
```
## Security Notes
1. All admin endpoints require admin role verification
2. JWT tokens are validated on every request
3. User passwords are hashed using bcrypt
4. All database queries use parameterized statements to prevent SQL injection
5. Admin actions are logged for audit purposes
## Database Schema
The admin endpoints interact with the following database tables:
- `users` - User accounts and profiles
- `jobs` - Job postings
- `interview_tokens` - Token purchases and usage
- `token_packages` - Available token packages
- `interviews` - Interview sessions
- `payment_records` - Payment history
- `user_usage` - Usage tracking and limits

View File

@ -0,0 +1,52 @@
# AI Configuration Guide
## Environment Variables
Add these to your `.env` file:
```env
# AI Configuration
# Choose between 'ollama' or 'openrouter'
AI_PROVIDER=openrouter
# Ollama Configuration (if AI_PROVIDER=ollama)
AI_PORT=11434
AI_MODEL=gpt-oss:20b
# OpenRouter Configuration (if AI_PROVIDER=openrouter)
OPENROUTER_API_KEY=sk-or-your-api-key-here
OPENROUTER_MODEL=gemma
OPENROUTER_BASE_URL=openrouter.ai
OPENROUTER_REL_PATH=/api
OPENROUTER_TEMPERATURE=0.7
```
## Available OpenRouter Models
Based on your C# implementation, these models are available:
- `gemma` - google/gemma-3-12b-it
- `dolphin` - cognitivecomputations/dolphin-mixtral-8x22b
- `dolphin_free` - cognitivecomputations/dolphin3.0-mistral-24b:free
- `gpt-4o-mini` - openai/gpt-4o-mini
- `gpt-4.1-nano` - openai/gpt-4.1-nano
- `qwen` - qwen/qwen3-30b-a3b
- `unslop` - thedrummer/unslopnemo-12b
- `euryale` - sao10k/l3.3-euryale-70b
- `wizard` - microsoft/wizardlm-2-8x22b
- `deepseek` - deepseek/deepseek-chat-v3-0324
- `dobby` - sentientagi/dobby-mini-unhinged-plus-llama-3.1-8b
## Testing
1. Set `AI_PROVIDER=openrouter` in your `.env`
2. Add your OpenRouter API key
3. Test the connection: `GET http://localhost:8083/rest/ai/test-ai`
4. Start an interview to test the full flow
## Switching Back to Ollama
To switch back to Ollama:
1. Set `AI_PROVIDER=ollama` in your `.env`
2. Make sure Ollama is running on the specified port
3. Test the connection: `GET http://localhost:8083/rest/ai/test-ai`

30
backend/Dockerfile Normal file
View File

@ -0,0 +1,30 @@
# Use Node.js 18 Alpine for smaller image size
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Expose port
EXPOSE 8083
# Install curl for health checks
RUN apk add --no-cache curl
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8083/rest/ai/test-ai || exit 1
# Start the application
CMD ["npm", "run", "start:prod"]

67
backend/README.md Normal file
View File

@ -0,0 +1,67 @@
<p style="text-align: center" align="center">
<a href="https://tsed.dev" target="_blank"><img src="https://tsed.dev/tsed-og.png" width="200" alt="Ts.ED logo"/></a>
</p>
<div align="center">
<h1>Ts.ED - backend</h1>
<br />
<div align="center">
<a href="https://cli.tsed.dev/">Website</a>
<span>&nbsp;&nbsp;&nbsp;&nbsp;</span>
<a href="https://cli.tsed.dev/getting-started.html">Getting started</a>
<span>&nbsp;&nbsp;&nbsp;&nbsp;</span>
<a href="https://slack.tsed.dev">Slack</a>
<span>&nbsp;&nbsp;&nbsp;&nbsp;</span>
<a href="https://twitter.com/TsED_io">Twitter</a>
</div>
<hr />
</div>
> An awesome project based on Ts.ED framework
## Getting started
> **Important!** Ts.ED requires Node >= 20.x or Bun.js and TypeScript >= 5.
```batch
# install dependencies
$ npm install
# serve
$ npm run start
# build for production
$ npm run build
$ npm run start:prod
```
## Docker
```
# build docker image
docker compose build
# start docker image
docker compose up
```
## Barrels
This project uses [barrels](https://www.npmjs.com/package/@tsed/barrels) to generate index files to import the controllers.
Edit `.barrels.json` to customize it:
```json
{
"directory": [
"./src/controllers/rest",
"./src/controllers/pages"
],
"exclude": [
"**/__mock__",
"**/__mocks__",
"**/*.spec.ts"
],
"delete": true
}
```

View File

@ -0,0 +1,3 @@
-- Add icon column to jobs table
USE candidb_main;
ALTER TABLE jobs ADD COLUMN icon VARCHAR(50) DEFAULT 'briefcase' AFTER application_deadline;

View File

@ -0,0 +1,7 @@
USE candidb_main;
-- Add interview_style column to jobs table
ALTER TABLE jobs ADD COLUMN interview_style ENUM('personal', 'balanced', 'technical') DEFAULT 'balanced' AFTER interview_questions;
-- Update existing records to have 'balanced' as default
UPDATE jobs SET interview_style = 'balanced' WHERE interview_style IS NULL;

View File

@ -0,0 +1,7 @@
USE candidb_main;
-- Add tokens_used column to job_links table
ALTER TABLE job_links ADD COLUMN tokens_used INT DEFAULT 0 AFTER tokens_available;
-- Update existing records to have 0 tokens_used
UPDATE job_links SET tokens_used = 0 WHERE tokens_used IS NULL;

View File

@ -0,0 +1,13 @@
USE candidb_main;
CREATE TABLE IF NOT EXISTS job_links (
id VARCHAR(36) PRIMARY KEY,
job_id VARCHAR(36) NOT NULL,
url_slug VARCHAR(50) NOT NULL UNIQUE,
tokens_available INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE,
INDEX idx_job_id (job_id),
INDEX idx_url_slug (url_slug)
);

View File

@ -0,0 +1,46 @@
version: '3.5'
services:
server:
build:
context: .
dockerfile: ./Dockerfile
args:
- http_proxy
- https_proxy
- no_proxy
image: backend/server:latest
ports:
- "8081:8081"
environment:
- CHATBOT_SERVICE_URL=http://chatbot:80
- CHATBOT_SERVICE_TIMEOUT=30000
- CHATBOT_FALLBACK_ENABLED=true
depends_on:
- chatbot
networks:
- candidat-network
chatbot:
build:
context: ../../tuna/tuna
dockerfile: Dockerfile
image: candidat/chatbot:latest
container_name: backend-chatbot
environment:
- ASPNETCORE_ENVIRONMENT=Production
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
- CHATBOT_DB_HOST=database
- CHATBOT_DB_NAME=${MYSQL_DATABASE}
- CHATBOT_DB_USER=${MYSQL_USER}
- CHATBOT_DB_PASSWORD=${MYSQL_PASSWORD}
- CHATBOT_DB_PORT=3306
ports:
- "5000:80"
networks:
- candidat-network
restart: unless-stopped
networks:
candidat-network:
external: true

9
backend/nodemon.json Normal file
View File

@ -0,0 +1,9 @@
{
"extensions": ["ts"],
"watch": ["src"],
"ignore": ["**/*.spec.ts"],
"delay": 100,
"execMap": {
"ts": "node --enable-source-maps --import @swc-node/register/esm-register"
}
}

5573
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

77
backend/package.json Normal file
View File

@ -0,0 +1,77 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"scripts": {
"build": "npm run barrels && swc src --out-dir dist -s --strip-leading-paths",
"barrels": "barrels",
"start": "npm run barrels && nodemon src/index.ts",
"start:prod": "cross-env NODE_ENV=production node dist/index.js"
},
"dependencies": {
"@swc-node/register": "^1.11.1",
"@swc/cli": "^0.7.8",
"@swc/core": "^1.13.5",
"@swc/helpers": "^0.5.17",
"@tsed/ajv": "^8.16.2",
"@tsed/barrels": "^6.6.3",
"@tsed/core": "^8.16.2",
"@tsed/di": "^8.16.2",
"@tsed/engines": "^8.16.2",
"@tsed/exceptions": "^8.16.2",
"@tsed/json-mapper": "^8.16.2",
"@tsed/logger": "^8.0.4",
"@tsed/openspec": "^8.16.2",
"@tsed/platform-cache": "^8.16.2",
"@tsed/platform-exceptions": "^8.16.2",
"@tsed/platform-express": "^8.16.2",
"@tsed/platform-http": "^8.16.2",
"@tsed/platform-log-request": "^8.16.2",
"@tsed/platform-middlewares": "^8.16.2",
"@tsed/platform-multer": "^8.16.2",
"@tsed/platform-params": "^8.16.2",
"@tsed/platform-response-filter": "^8.16.2",
"@tsed/platform-views": "^8.16.2",
"@tsed/scalar": "^8.16.2",
"@tsed/schema": "^8.16.2",
"@tsed/socketio": "^8.16.2",
"@tsed/swagger": "^8.16.2",
"@types/bcryptjs": "^3.0.0",
"@types/jsonwebtoken": "^9.0.10",
"ajv": "^8.17.1",
"axios": "^1.6.0",
"bcryptjs": "^3.0.2",
"body-parser": "^2.2.0",
"compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cross-env": "^10.0.0",
"dotenv": "^17.2.2",
"dotenv-expand": "^12.0.3",
"dotenv-flow": "^4.1.0",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"method-override": "^3.0.0",
"mysql2": "^3.14.5",
"socket.io": "^4.8.1",
"typescript": "^5.9.2"
},
"devDependencies": {
"@types/compression": "^1.8.1",
"@types/cookie-parser": "^1.4.9",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/method-override": "^3.0.0",
"@types/multer": "^2.0.0",
"@types/node": "^24.3.1",
"nodemon": "^3.1.10",
"tslib": "^2.8.1"
},
"tsed": {
"convention": "conv_default",
"architecture": "arc_default",
"packageManager": "npm",
"runtime": "node"
},
"type": "module"
}

View File

@ -0,0 +1,22 @@
'use strict'
const path = require('path')
const defaultLogFile = path.join(__dirname, '/logs/project-server.log')
module.exports = {
'apps': [
{
name: 'api',
'script': `${process.env.WORKDIR}/dist/index.js`,
'cwd': process.env.WORKDIR,
exec_mode: "cluster",
instances: process.env.NODE_ENV === 'test' ? 1 : process.env.NB_INSTANCES || 2,
autorestart: true,
max_memory_restart: process.env.MAX_MEMORY_RESTART || '750M',
'out_file': defaultLogFile,
'error_file': defaultLogFile,
'merge_logs': true,
'kill_timeout': 30000,
}
]
}

2
backend/secret.txt Normal file
View File

@ -0,0 +1,2 @@
Portainer: 168.231.108.135:9443 / tundaadmin / retoortunapass1

101
backend/src/Server.ts Normal file
View File

@ -0,0 +1,101 @@
import {join} from "node:path";
import {Configuration} from "@tsed/di";
import {application} from "@tsed/platform-http";
import "@tsed/platform-log-request"; // remove this import if you don't want log request
import "@tsed/platform-express"; // /!\ keep this import
import "@tsed/ajv";
import "@tsed/swagger";
import "@tsed/scalar";
import {config} from "./config/index.js";
import * as rest from "./controllers/rest/index.js";
import * as pages from "./controllers/pages/index.js";
import {testConnection, closePool} from "./config/database.js";
import {$log} from "@tsed/logger";
@Configuration({
...config,
acceptMimes: ["application/json"],
httpPort: process.env.PORT || 8083,
httpsPort: false, // CHANGE
mount: {
"/rest": [
...Object.values(rest)
],
"/": [
...Object.values(pages)
]
},
swagger: [
{
path: "/doc",
specVersion: "3.0.1",
spec: {
info: {
title: "Candivista API",
version: process.env.APP_VERSION || "1.0.0",
description:
"REST API for Candivista. Authentication via JWT Bearer tokens.\n\n" +
"Includes endpoints for auth, users, jobs, tokens, AI, and admin reporting.",
contact: {
name: "Candivista Team",
url: "https://candivista.com",
email: "support@candivista.com"
},
license: { name: "Proprietary" }
},
servers: [
{ url: "http://localhost:8083", description: "Local" }
],
tags: [
{ name: "Auth", description: "Authentication and session management" },
{ name: "Users", description: "User profile and token summary" },
{ name: "Jobs", description: "Job posting and interview token operations" },
{ name: "Admin", description: "Administrative statistics and management" },
{ name: "AI", description: "AI provider tests and operations" }
],
components: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" }
}
},
security: [{ bearerAuth: [] }]
}
}
],
scalar: [
{
path: "/scalar/doc",
specVersion: "3.0.1"
}
],
middlewares: [
"cors",
"cookie-parser",
"compression",
"method-override",
"json-parser",
{ use: "urlencoded-parser", options: { extended: true }}
],
views: {
root: join(process.cwd(), "views"),
extensions: {
ejs: "ejs"
}
}
})
export class Server {
protected app = application();
async $onInit() {
// Test database connection on startup
const isConnected = await testConnection();
if (!isConnected) {
$log.error("Failed to connect to database. Server will continue but database operations may fail.");
}
}
async $onDestroy() {
// Close database pool on shutdown
await closePool();
}
}

View File

@ -0,0 +1,54 @@
import mysql from 'mysql2/promise';
import { $log } from '@tsed/logger';
export interface DatabaseConfig {
host: string;
port: number;
user: string;
password: string;
database: string;
connectionLimit: number;
}
const config: DatabaseConfig = {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'candidb_main',
connectionLimit: parseInt(process.env.DB_CONNECTION_LIMIT || '10')
};
// Create connection pool
export const pool = mysql.createPool({
...config,
waitForConnections: true,
queueLimit: 0,
acquireTimeout: 60000,
timeout: 60000,
reconnect: true
});
// Test database connection
export async function testConnection(): Promise<boolean> {
try {
const connection = await pool.getConnection();
await connection.ping();
connection.release();
$log.info('Database connection established successfully');
return true;
} catch (error) {
$log.error('Database connection failed:', error);
return false;
}
}
// Graceful shutdown
export async function closePool(): Promise<void> {
try {
await pool.end();
$log.info('Database pool closed');
} catch (error) {
$log.error('Error closing database pool:', error);
}
}

View File

@ -0,0 +1,7 @@
import dotenv from "dotenv-flow";
process.env.NODE_ENV = process.env.NODE_ENV || "development";
export const config = dotenv.config();
export const isProduction = process.env.NODE_ENV === "production";
export const envs = process.env

View File

@ -0,0 +1,14 @@
import {readFileSync} from "node:fs";
import {envs} from "./envs/index.js";
import loggerConfig from "./logger/index.js";
const pkg = JSON.parse(readFileSync("./package.json", {encoding: "utf8"}));
export const config: Partial<TsED.Configuration> = {
version: pkg.version,
envs,
ajv: {
returnsCoercedValues: true
},
logger: loggerConfig,
// additional shared configuration
};

View File

@ -0,0 +1,25 @@
import {DILoggerOptions} from "@tsed/di";
import {$log} from "@tsed/logger";
import {isProduction} from "../envs/index.js";
if (isProduction) {
$log.appenders.set("stdout", {
type: "stdout",
levels: ["info", "debug"],
layout: {
type: "json"
}
});
$log.appenders.set("stderr", {
levels: ["trace", "fatal", "error", "warn"],
type: "stderr",
layout: {
type: "json"
}
});
}
export default <DILoggerOptions> {
disableRoutesSummary: isProduction
};

View File

@ -0,0 +1,29 @@
import {Constant, Controller} from "@tsed/di";
import {HeaderParams} from "@tsed/platform-params";
import {View} from "@tsed/platform-views";
import {SwaggerSettings} from "@tsed/swagger";
import {Hidden, Get, Returns} from "@tsed/schema";
@Hidden()
@Controller("/")
export class IndexController {
@Constant("swagger", [])
private swagger: SwaggerSettings[];
@Get("/")
@View("swagger.ejs")
@(Returns(200, String).ContentType("text/html"))
get(@HeaderParams("x-forwarded-proto") protocol: string, @HeaderParams("host") host: string) {
const hostUrl = `${protocol || "http"}://${host}`;
return {
BASE_URL: hostUrl,
docs: this.swagger.map((conf) => {
return {
url: hostUrl + conf.path,
...conf
};
})
};
}
}

View File

@ -0,0 +1,4 @@
/**
* @file Automatically generated by @tsed/barrels.
*/
export * from "./IndexController.js";

View File

@ -0,0 +1,644 @@
import { Controller } from "@tsed/di";
import { Post, Get } from "@tsed/schema";
import { BodyParams, PathParams, QueryParams } from "@tsed/platform-params";
import { Req } from "@tsed/platform-http";
import { BadRequest, NotFound } from "@tsed/exceptions";
import { JobService } from "../../services/JobService.js";
import { AIService } from "../../services/AIService.js";
import axios from "axios";
@Controller("/ai")
export class AIController {
private jobService = new JobService();
private aiService = new AIService();
private aiProvider = process.env.AI_PROVIDER || 'ollama'; // 'ollama' or 'openrouter'
private aiPort = process.env.AI_PORT || '11434';
private aiModel = process.env.AI_MODEL || 'gpt-oss:20b';
// Test AI connection
@Get("/test-ai")
async testAI() {
try {
if (this.aiProvider === 'openrouter') {
const response = await this.aiService.generateResponse("Hello, please respond with exactly: 'AI is working'");
return {
success: true,
aiResponse: response,
provider: 'openrouter',
model: process.env.OPENROUTER_MODEL || 'gemma'
};
} else {
// Ollama test
const response = await axios.post(`http://localhost:${this.aiPort}/api/generate`, {
model: this.aiModel,
prompt: "Hello, please respond with exactly: 'AI is working'",
stream: false,
options: {
temperature: 0.1,
max_tokens: 50
}
});
return {
success: true,
aiResponse: response.data.response,
provider: 'ollama',
model: this.aiModel,
port: this.aiPort
};
}
} catch (error) {
console.error('AI test failed:', error);
return {
success: false,
error: error.message,
provider: this.aiProvider,
model: this.aiProvider === 'openrouter' ? process.env.OPENROUTER_MODEL : this.aiModel
};
}
}
// Get mandatory questions for the job
@Get("/mandatory-questions/:linkId")
async getMandatoryQuestions(@PathParams("linkId") linkId: string) {
try {
// Verify the job exists and link is valid
const jobData = await this.jobService.getJobByLinkId(linkId);
if (!jobData) {
throw new NotFound("Interview link not found or expired");
}
const mandatoryQuestions = jobData.interview_questions || [];
return {
success: true,
questions: mandatoryQuestions,
hasMandatoryQuestions: mandatoryQuestions.length > 0
};
} catch (error) {
console.error('Error getting mandatory questions:', error);
throw error;
}
}
// Submit mandatory question answers
@Post("/submit-mandatory-answers")
async submitMandatoryAnswers(@BodyParams() body: any, @QueryParams() query: any) {
try {
const { candidateName, job, linkId, answers } = body;
const isTestMode = query.test === 'true' || body.test === true;
if (!candidateName || !job || !linkId || !answers) {
throw new BadRequest("Missing required fields: candidateName, job, linkId, answers");
}
// Verify the job exists and link is valid
const jobData = await this.jobService.getJobByLinkId(linkId);
if (!jobData) {
throw new NotFound("Interview link not found or expired");
}
// Create or get interview record (skip DB writes in test mode)
const interviewId = await this.jobService.getOrCreateInterview(linkId, candidateName, isTestMode);
// Save all mandatory question answers
for (let i = 0; i < answers.length; i++) {
const question = jobData.interview_questions[i];
const answer = answers[i];
if (question && answer) {
// Save as AI message (question)
await this.jobService.saveConversationMessage(interviewId, linkId, 'ai', `Question ${i + 1}: ${question}`, isTestMode);
// Save as candidate message (answer)
await this.jobService.saveConversationMessage(interviewId, linkId, 'candidate', answer, isTestMode);
}
}
// Log mandatory questions completed
await this.jobService.logInterviewEvent(linkId, 'mandatory_questions_completed', {
candidateName,
interviewId,
questionsAnswered: answers.length,
timestamp: new Date().toISOString()
});
return {
success: true,
message: "Mandatory questions answered successfully",
interviewId
};
} catch (error) {
console.error('Error submitting mandatory answers:', error);
throw error;
}
}
// Start interview with AI agent (only after mandatory questions)
@Post("/start-interview")
async startInterview(@BodyParams() body: any, @QueryParams() query: any) {
try {
const { candidateName, job, linkId } = body;
const isTestMode = query.test === 'true' || body.test === true;
if (!candidateName || !job || !linkId) {
throw new BadRequest("Missing required fields: candidateName, job, linkId");
}
// Verify the job exists and link is valid
const jobData = await this.jobService.getJobByLinkId(linkId);
if (!jobData) {
throw new NotFound("Interview link not found or expired");
}
// Create or get interview record (skip DB writes in test mode)
const interviewId = await this.jobService.getOrCreateInterview(linkId, candidateName, isTestMode);
// Get conversation history to include mandatory question answers
let conversationHistory;
if (isTestMode) {
// In test mode, we can't get conversation history from DB since we don't save
// The frontend should pass the mandatory question answers in the request
conversationHistory = [];
console.log(`[DEBUG] Starting AI in test mode - no conversation history available`);
} else {
// In production mode, get from database
conversationHistory = await this.jobService.getConversationHistory(interviewId);
console.log(`[DEBUG] Starting AI with conversation history: ${JSON.stringify(conversationHistory, null, 2)}`);
}
// Generate initial AI message using chatbot service (fail if AI unavailable)
const initialMessage = await this.aiService.initializeInterviewWithChatbot(job, candidateName, linkId, conversationHistory);
console.log(`[DEBUG] initializeInterviewWithChatbot returned: "${initialMessage}"`);
if (!initialMessage) {
throw new Error("AI service is currently unavailable. Please try again later.");
}
// Save AI message to conversation
await this.jobService.saveConversationMessage(interviewId, linkId, 'ai', initialMessage, isTestMode);
// Log interview start
await this.jobService.logInterviewEvent(linkId, 'started', {
candidateName,
interviewId,
timestamp: new Date().toISOString()
});
return {
success: true,
message: initialMessage,
job: jobData,
interviewId
};
} catch (error: any) {
console.error('Error starting interview:', error);
throw error;
}
}
// Handle chat messages
@Post("/chat")
async handleChat(@BodyParams() body: any, @QueryParams() query: any) {
try {
const { message, candidateName, job, linkId, conversationHistory } = body;
const isTestMode = query.test === 'true' || body.test === true;
if (!message || !candidateName || !job || !linkId) {
throw new BadRequest("Missing required fields: message, candidateName, job, linkId");
}
// Verify the job exists and link is valid
const jobData = await this.jobService.getJobByLinkId(linkId);
if (!jobData) {
throw new NotFound("Interview link not found or expired");
}
// Get or create interview record
const interviewId = await this.jobService.getOrCreateInterview(linkId, candidateName, isTestMode);
// Save user message to conversation
await this.jobService.saveConversationMessage(interviewId, linkId, 'candidate', message, isTestMode);
// Get conversation history - use frontend data in test mode, database in production
let conversationHistoryToUse;
if (isTestMode) {
// In test mode, use the conversation history passed from frontend
conversationHistoryToUse = conversationHistory || [];
console.log(`[DEBUG] Using frontend conversation history (test mode): ${JSON.stringify(conversationHistoryToUse, null, 2)}`);
// Filter out any messages with undefined content
conversationHistoryToUse = conversationHistoryToUse.filter(msg =>
msg && msg.message && msg.message !== 'undefined' && msg.sender
);
console.log(`[DEBUG] Filtered conversation history: ${JSON.stringify(conversationHistoryToUse, null, 2)}`);
} else {
// In production mode, get from database
conversationHistoryToUse = await this.jobService.getConversationHistory(interviewId);
console.log(`[DEBUG] Retrieved conversation history from database: ${JSON.stringify(conversationHistoryToUse, null, 2)}`);
}
// Generate AI response using chatbot service
const aiResponse = await this.generateAIResponseWithChatbot(message, job, conversationHistoryToUse, candidateName, linkId);
console.log(`[DEBUG] generateAIResponseWithChatbot returned:`, aiResponse);
if (!aiResponse) {
throw new Error("AI service is currently unavailable. Please try again later.");
}
// Save AI response to conversation
await this.jobService.saveConversationMessage(interviewId, linkId, 'ai', aiResponse.message, isTestMode);
// Log the messages
await this.jobService.logInterviewEvent(linkId, 'user_message', {
candidateName,
message,
interviewId,
timestamp: new Date().toISOString()
});
await this.jobService.logInterviewEvent(linkId, 'ai_message', {
candidateName,
message: aiResponse.message,
interviewId,
timestamp: new Date().toISOString()
});
return {
success: true,
message: aiResponse.message,
isComplete: aiResponse.isComplete
};
} catch (error: any) {
console.error('Error handling chat:', error);
throw error;
}
}
// Get conversation history
@Get("/conversation/:linkId")
async getConversation(@PathParams("linkId") linkId: string) {
try {
const jobData = await this.jobService.getJobByLinkId(linkId);
if (!jobData) {
throw new NotFound("Interview link not found or expired");
}
const interviewId = await this.jobService.getInterviewIdByLink(linkId);
if (!interviewId) {
return {
success: true,
messages: []
};
}
const messages = await this.jobService.getConversationHistory(interviewId);
return {
success: true,
messages: messages
};
} catch (error: any) {
console.error('Error getting conversation:', error);
throw error;
}
}
// End interview
@Post("/end-interview/:linkId")
async endInterview(@PathParams("linkId") linkId: string) {
try {
const jobData = await this.jobService.getJobByLinkId(linkId);
if (!jobData) {
throw new NotFound("Interview link not found or expired");
}
const interviewId = await this.jobService.getInterviewIdByLink(linkId);
if (!interviewId) {
throw new NotFound("Interview not found");
}
// End interview with chatbot service
await this.aiService.endInterviewWithChatbot(linkId);
// Mark interview as completed
await this.jobService.completeInterview(interviewId);
// Log interview completion
await this.jobService.logInterviewEvent(linkId, 'completed', {
interviewId,
timestamp: new Date().toISOString()
});
return {
success: true,
message: "Interview completed successfully"
};
} catch (error: any) {
console.error('Error ending interview:', error);
throw error;
}
}
private async generateInitialMessage(job: any, candidateName: string, conversationHistory: any[] = []): Promise<string | null> {
const skills = job.skills_required ? job.skills_required.join(', ') : 'various technical skills';
const experience = job.experience_level.replace('_', ' ');
// Build context from conversation history (mandatory question answers)
const conversationContext = conversationHistory
.map(msg => `${msg.sender === 'candidate' ? 'Candidate' : 'Interviewer'}: ${msg.message}`)
.join('\n');
const systemMessage = `You are an AI interview agent conducting an interview for the position: ${job.title}
Job Description: ${job.description}
Requirements: ${job.requirements}
Required Skills: ${skills}
Experience Level: ${experience}
Location: ${job.location || 'Remote'}
${conversationContext ? `Previous conversation (mandatory questions answered):
${conversationContext}
Based on the candidate's answers to the mandatory questions above, you should now conduct a deeper interview.` : ''}
Your task is to:
1. Greet the candidate warmly and professionally
2. Introduce yourself as their evaluation agent
3. ${conversationContext ? 'Acknowledge their previous answers and build upon them' : 'Explain that you\'ll be conducting a comprehensive interview'}
4. Ask them to tell you about themselves and their interest in this role
5. Keep your response conversational and engaging
6. Don't ask multiple questions at once - start with one open-ended question
Respond in a friendly, professional tone. Keep it concise but welcoming.`;
const userPrompt = `The candidate's name is ${candidateName}. Please start the interview.`;
try {
if (this.aiProvider === 'openrouter') {
const response = await this.aiService.generateResponse(userPrompt, systemMessage);
if (response) {
return response;
} else {
console.log('[WARN] OpenRouter failed, falling back to Ollama');
// Fallback to Ollama if OpenRouter fails
}
}
// Ollama fallback (either configured or as fallback)
const response = await axios.post(`http://localhost:${this.aiPort}/api/generate`, {
model: this.aiModel,
prompt: `${systemMessage}\n\n${userPrompt}`,
stream: false,
options: {
temperature: 0.7,
max_tokens: 500
}
});
return response.data.response || null;
} catch (error) {
console.error('Error calling AI:', error);
return null; // Return null instead of fallback message
}
}
private async generateAIResponseWithChatbot(userMessage: string, job: any, conversationHistory: any[], candidateName: string, linkId: string): Promise<{ message: string; isComplete: boolean } | null> {
// Check if we should end the interview (after 10+ exchanges)
const userMessages = conversationHistory.filter(msg => msg.sender === 'candidate').length;
const shouldEnd = userMessages >= 10;
if (shouldEnd) {
const endPrompt = `The interview is coming to a close. The candidate has provided comprehensive responses about their background and experience for the ${job.title} position.
Please provide a professional closing message that:
1. Thanks the candidate for their time and thoughtful responses
2. Acknowledges their qualifications and interest
3. Explains that their responses will be reviewed by the hiring team
4. Mentions they should expect to hear back within a few business days
5. Keeps it warm and professional
Keep it concise and professional.`;
try {
const response = await this.aiService.generateResponseWithChatbot(
endPrompt,
conversationHistory,
undefined,
job,
candidateName,
linkId
);
return {
message: response || "Thank you for your time and detailed responses. That concludes our interview. We'll review your answers and get back to you within a few business days.",
isComplete: true
};
} catch (error) {
console.error('Error calling chatbot for end message:', error);
return {
message: "Thank you for your time and detailed responses. That concludes our interview. We'll review your answers and get back to you within a few business days.",
isComplete: true
};
}
}
// Build context for ongoing conversation
const conversationContext = conversationHistory
.slice(-6) // Last 6 messages for context
.map(msg => `${msg.sender === 'candidate' ? 'Candidate' : 'Interviewer'}: ${msg.message}`)
.join('\n');
// Debug logging
console.log(`[DEBUG] Conversation history length: ${conversationHistory.length}`);
console.log(`[DEBUG] Conversation context: ${conversationContext}`);
console.log(`[DEBUG] User message: ${userMessage}`);
const systemMessage = `You are an AI interview agent conducting an interview for the position: ${job.title}
Job Details:
- Title: ${job.title}
- Description: ${job.description}
- Requirements: ${job.requirements}
- Required Skills: ${job.skills_required ? job.skills_required.join(', ') : 'Various technical skills'}
- Experience Level: ${job.experience_level.replace('_', ' ')}
CRITICAL INSTRUCTIONS:
1. You MUST acknowledge the candidate's response first
2. You MUST then ask ONE specific follow-up question
3. The question should be relevant to their answer and help evaluate their fit for the ${job.title} role
4. Focus on technical skills, experience, problem-solving, or behavioral aspects
5. Keep the question specific and engaging
6. Do NOT repeat the same question
7. Do NOT ask multiple questions at once
8. Maintain a professional but conversational tone
RESPONSE FORMAT:
- Start with a brief acknowledgment of their answer
- Then ask exactly one follow-up question
- End your response after the question
Example:
"Thanks for sharing that experience with React. That's exactly the kind of hands-on development we're looking for. Can you tell me about a specific challenge you faced while building that application and how you solved it?"`;
const userPrompt = `Recent conversation:
${conversationContext}
Candidate's latest response: ${userMessage}
Please respond with an acknowledgment and follow-up question.`;
try {
const aiResponse = await this.aiService.generateResponseWithChatbot(
userMessage,
conversationHistory,
systemMessage,
job,
candidateName,
linkId
);
if (aiResponse) {
console.log(`[DEBUG] Chatbot Response: ${aiResponse}`);
return {
message: aiResponse,
isComplete: false
};
} else {
console.log('[WARN] Chatbot service failed, falling back to direct OpenRouter');
// Fallback to original method
return await this.generateAIResponse(userMessage, job, conversationHistory, candidateName);
}
} catch (error) {
console.error('Error calling chatbot service:', error);
// Fallback to original method
return await this.generateAIResponse(userMessage, job, conversationHistory, candidateName);
}
}
private async generateAIResponse(userMessage: string, job: any, conversationHistory: any[], candidateName: string): Promise<{ message: string; isComplete: boolean } | null> {
// Check if we should end the interview (after 10+ exchanges)
const userMessages = conversationHistory.filter(msg => msg.sender === 'candidate').length;
const shouldEnd = userMessages >= 10;
if (shouldEnd) {
const endPrompt = `The interview is coming to a close. The candidate has provided comprehensive responses about their background and experience for the ${job.title} position.
Please provide a professional closing message that:
1. Thanks the candidate for their time and thoughtful responses
2. Acknowledges their qualifications and interest
3. Explains that their responses will be reviewed by the hiring team
4. Mentions they should expect to hear back within a few business days
5. Keeps it warm and professional
Keep it concise and professional.`;
try {
const response = await axios.post(`http://localhost:${this.aiPort}/api/generate`, {
model: this.aiModel,
prompt: endPrompt,
stream: false,
options: {
temperature: 0.7,
max_tokens: 300
}
});
return {
message: response.data.response || null,
isComplete: true
};
} catch (error) {
console.error('Error calling Ollama for end message:', error);
return null; // Return null instead of fallback message
}
}
// Build context for ongoing conversation
const conversationContext = conversationHistory
.slice(-6) // Last 6 messages for context
.map(msg => `${msg.sender === 'candidate' ? 'Candidate' : 'Interviewer'}: ${msg.message}`)
.join('\n');
// Debug logging
console.log(`[DEBUG] Conversation history length: ${conversationHistory.length}`);
console.log(`[DEBUG] Conversation context: ${conversationContext}`);
console.log(`[DEBUG] User message: ${userMessage}`);
const systemMessage = `You are an AI interview agent conducting an interview for the position: ${job.title}
Job Details:
- Title: ${job.title}
- Description: ${job.description}
- Requirements: ${job.requirements}
- Required Skills: ${job.skills_required ? job.skills_required.join(', ') : 'Various technical skills'}
- Experience Level: ${job.experience_level.replace('_', ' ')}
CRITICAL INSTRUCTIONS:
1. You MUST acknowledge the candidate's response first
2. You MUST then ask ONE specific follow-up question
3. The question should be relevant to their answer and help evaluate their fit for the ${job.title} role
4. Focus on technical skills, experience, problem-solving, or behavioral aspects
5. Keep the question specific and engaging
6. Do NOT repeat the same question
7. Do NOT ask multiple questions at once
8. Maintain a professional but conversational tone
RESPONSE FORMAT:
- Start with a brief acknowledgment of their answer
- Then ask exactly one follow-up question
- End your response after the question
Example:
"Thanks for sharing that experience with React. That's exactly the kind of hands-on development we're looking for. Can you tell me about a specific challenge you faced while building that application and how you solved it?"`;
const userPrompt = `Recent conversation:
${conversationContext}
Candidate's latest response: ${userMessage}
Please respond with an acknowledgment and follow-up question.`;
try {
if (this.aiProvider === 'openrouter') {
const aiResponse = await this.aiService.generateResponseWithHistory(userMessage, conversationHistory, systemMessage);
if (aiResponse) {
console.log(`[DEBUG] OpenRouter Response: ${aiResponse}`);
return {
message: aiResponse,
isComplete: false
};
} else {
console.log('[WARN] OpenRouter failed, falling back to Ollama');
// Fallback to Ollama if OpenRouter fails
}
}
// Ollama fallback (either configured or as fallback)
console.log(`[DEBUG] Sending to Ollama - Port: ${this.aiPort}, Model: ${this.aiModel}`);
console.log(`[DEBUG] Prompt length: ${systemMessage.length + userPrompt.length} characters`);
const response = await axios.post(`http://localhost:${this.aiPort}/api/generate`, {
model: this.aiModel,
prompt: `${systemMessage}\n\n${userPrompt}`,
stream: false,
options: {
temperature: 0.7,
max_tokens: 400
}
});
const aiResponse = response.data.response || null;
console.log(`[DEBUG] Ollama Response: ${aiResponse}`);
return {
message: aiResponse,
isComplete: false
};
} catch (error) {
console.error('Error calling AI:', error);
return null; // Return null instead of fallback message
}
}
}

View File

@ -0,0 +1,249 @@
import { Controller } from "@tsed/di";
import { Get, Post, Put, Patch, Delete, Tags, Summary, Description, Returns, Security } from "@tsed/schema";
import { BodyParams, PathParams, QueryParams } from "@tsed/platform-params";
import { Req } from "@tsed/platform-http";
import { BadRequest, Unauthorized, NotFound } from "@tsed/exceptions";
import jwt from "jsonwebtoken";
import { AdminService } from "../../services/AdminService.js";
import { UserService } from "../../services/UserService.js";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
@Controller("/admin")
@Tags("Admin")
@Security("bearerAuth")
export class AdminController {
private adminService = new AdminService();
private userService = new UserService();
// Middleware to check if user is admin
private async checkAdmin(req: any) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
throw new Unauthorized("No token provided");
}
try {
const decoded = jwt.verify(token, JWT_SECRET) as any;
const user = await this.userService.getUserById(decoded.userId);
if (!user) {
throw new Unauthorized("User not found");
}
if (user.role !== 'admin') {
throw new Unauthorized("Admin access required");
}
return user;
} catch (error) {
throw new Unauthorized("Invalid token or insufficient permissions");
}
}
// System Statistics
@Get("/statistics")
@Summary("Get system statistics")
@Description("High-level metrics: users, jobs, interviews, tokens, revenue")
@(Returns(200).Description("Statistics returned"))
@(Returns(401).Description("Unauthorized"))
async getSystemStatistics(@Req() req: any) {
await this.checkAdmin(req);
return await this.adminService.getSystemStatistics();
}
// User Management
@Get("/users")
@Summary("List all users")
@(Returns(200).Description("Users returned"))
async getAllUsers(@Req() req: any) {
await this.checkAdmin(req);
return await this.adminService.getAllUsers();
}
@Get("/users/:id")
@Summary("Get a user by ID")
@(Returns(200).Description("User returned"))
@(Returns(404).Description("User not found"))
async getUserById(@Req() req: any, @PathParams("id") id: string) {
await this.checkAdmin(req);
return await this.adminService.getUserById(id);
}
@Put("/users/:id")
@Summary("Update a user")
@(Returns(200).Description("User updated"))
async updateUser(
@Req() req: any,
@PathParams("id") id: string,
@BodyParams() userData: any
) {
await this.checkAdmin(req);
return await this.adminService.updateUser(id, userData);
}
@Patch("/users/:id/toggle-status")
@Summary("Toggle user active status")
@(Returns(200).Description("User status toggled"))
async toggleUserStatus(@Req() req: any, @PathParams("id") id: string) {
await this.checkAdmin(req);
return await this.adminService.toggleUserStatus(id);
}
@Patch("/users/:id/password")
@Summary("Change user password")
@(Returns(200).Description("Password updated"))
async changeUserPassword(
@Req() req: any,
@PathParams("id") id: string,
@BodyParams() body: { new_password: string }
) {
await this.checkAdmin(req);
return await this.adminService.changeUserPassword(id, body.new_password);
}
@Post("/users")
@Summary("Create a user")
@(Returns(200).Description("User created"))
async createUser(@Req() req: any, @BodyParams() userData: any) {
await this.checkAdmin(req);
return await this.adminService.createUser(userData);
}
// Job Management
@Get("/jobs")
@Summary("List all jobs")
@(Returns(200).Description("Jobs returned"))
async getAllJobs(@Req() req: any) {
await this.checkAdmin(req);
return await this.adminService.getAllJobs();
}
@Get("/jobs/:id")
@Summary("Get job by ID")
@(Returns(200).Description("Job returned"))
async getJobById(@Req() req: any, @PathParams("id") id: string) {
await this.checkAdmin(req);
return await this.adminService.getJobById(id);
}
@Patch("/jobs/:id/status")
@Summary("Update job status")
@(Returns(200).Description("Job status updated"))
async updateJobStatus(
@Req() req: any,
@PathParams("id") id: string,
@BodyParams() body: { status: string }
) {
await this.checkAdmin(req);
return await this.adminService.updateJobStatus(id, body.status);
}
@Put("/jobs/:id")
@Summary("Update job details")
@(Returns(200).Description("Job updated"))
async updateJob(
@Req() req: any,
@PathParams("id") id: string,
@BodyParams() jobData: any
) {
await this.checkAdmin(req);
return await this.adminService.updateJob(id, jobData);
}
// Token Management
@Get("/user-token-summaries")
@Summary("List user token summaries")
@(Returns(200).Description("Summaries returned"))
async getUserTokenSummaries(@Req() req: any) {
await this.checkAdmin(req);
return await this.adminService.getUserTokenSummaries();
}
@Post("/add-tokens")
@Summary("Add tokens to a user")
@(Returns(200).Description("Tokens added"))
async addTokensToUser(@Req() req: any, @BodyParams() tokenData: any) {
await this.checkAdmin(req);
return await this.adminService.addTokensToUser(tokenData);
}
@Get("/token-packages")
@Summary("List token packages")
@(Returns(200).Description("Packages returned"))
async getTokenPackages(@Req() req: any) {
await this.checkAdmin(req);
return await this.adminService.getTokenPackages();
}
@Post("/token-packages")
@Summary("Create token package")
@(Returns(200).Description("Package created"))
async createTokenPackage(@Req() req: any, @BodyParams() packageData: any) {
await this.checkAdmin(req);
return await this.adminService.createTokenPackage(packageData);
}
@Put("/token-packages/:id")
@Summary("Update token package")
@(Returns(200).Description("Package updated"))
async updateTokenPackage(
@Req() req: any,
@PathParams("id") id: string,
@BodyParams() packageData: any
) {
await this.checkAdmin(req);
return await this.adminService.updateTokenPackage(id, packageData);
}
@Patch("/token-packages/:id/toggle-status")
@Summary("Toggle token package active status")
@(Returns(200).Description("Package status toggled"))
async toggleTokenPackageStatus(@Req() req: any, @PathParams("id") id: string) {
await this.checkAdmin(req);
return await this.adminService.toggleTokenPackageStatus(id);
}
@Delete("/token-packages/:id")
@Summary("Delete token package")
@(Returns(200).Description("Package deleted"))
async deleteTokenPackage(@Req() req: any, @PathParams("id") id: string) {
await this.checkAdmin(req);
return await this.adminService.deleteTokenPackage(id);
}
// Interview Management
@Get("/interviews")
@Summary("List interviews")
@(Returns(200).Description("Interviews returned"))
async getAllInterviews(@Req() req: any) {
await this.checkAdmin(req);
return await this.adminService.getAllInterviews();
}
@Get("/interviews/:id")
@Summary("Get interview by ID")
@(Returns(200).Description("Interview returned"))
async getInterviewById(@Req() req: any, @PathParams("id") id: string) {
await this.checkAdmin(req);
return await this.adminService.getInterviewById(id);
}
// Payment Records
@Get("/payments")
@Summary("List payment records")
@(Returns(200).Description("Payments returned"))
async getPaymentRecords(@Req() req: any) {
await this.checkAdmin(req);
return await this.adminService.getPaymentRecords();
}
@Get("/payments/:id")
@Summary("Get payment by ID")
@(Returns(200).Description("Payment returned"))
async getPaymentById(@Req() req: any, @PathParams("id") id: string) {
await this.checkAdmin(req);
return await this.adminService.getPaymentById(id);
}
}

View File

@ -0,0 +1,167 @@
import { Controller } from "@tsed/di";
import { Post, Get, Summary, Description, Returns, Tags, Security } from "@tsed/schema";
import { BodyParams } from "@tsed/platform-params";
import { Req } from "@tsed/platform-http";
import { BadRequest, Unauthorized } from "@tsed/exceptions";
import jwt from "jsonwebtoken";
import { UserService } from "../../services/UserService.js";
import { User, CreateUserRequest, UpdateUserRequest, UserResponse } from "../../models/User.js";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
@Controller("/auth")
@Tags("Auth")
export class AuthController {
private userService = new UserService();
@Post("/login")
@Summary("Authenticate and obtain a JWT")
@Description("Provide email and password to receive a signed JWT used for subsequent requests.")
@Returns(200).Description("Successful authentication")
@Returns(400).Description("Missing email or password")
@Returns(401).Description("Invalid credentials or deactivated account")
async login(@BodyParams() body: { email: string; password: string }) {
const { email, password } = body;
if (!email || !password) {
throw new BadRequest("Email and password are required");
}
const user = await this.userService.getUserByEmail(email);
if (!user) {
throw new Unauthorized("Invalid credentials");
}
if (!user.is_active) {
throw new Unauthorized("Account is deactivated");
}
const isValidPassword = await this.userService.verifyPassword(user, password);
if (!isValidPassword) {
throw new Unauthorized("Invalid credentials");
}
// Update last login
await this.userService.updateLastLogin(user.id);
const token = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: "24h" }
);
return {
token,
user: {
id: user.id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
company_name: user.company_name,
avatar_url: user.avatar_url,
is_active: user.is_active,
last_login_at: user.last_login_at,
email_verified_at: user.email_verified_at,
created_at: user.created_at,
updated_at: user.updated_at
}
};
}
@Post("/register")
@Summary("Register a new recruiter user")
@Description("Creates a recruiter account and returns a JWT for immediate use.")
@Returns(200).Description("User created and token issued")
@Returns(400).Description("Validation failed or email already exists")
async register(@BodyParams() body: { email: string; password: string; first_name: string; last_name: string; company_name?: string }) {
const { email, password, first_name, last_name, company_name } = body;
if (!email || !password || !first_name || !last_name) {
throw new BadRequest("Email, password, first name, and last name are required");
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new BadRequest("Invalid email format");
}
// Validate password strength
if (password.length < 8) {
throw new BadRequest("Password must be at least 8 characters long");
}
try {
const user = await this.userService.createUser({
email,
password,
first_name,
last_name,
company_name,
role: 'recruiter'
});
// Generate token
const token = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: "24h" }
);
return {
token,
user
};
} catch (error: any) {
if (error.message.includes('already exists')) {
throw new BadRequest("User with this email already exists");
}
throw new BadRequest("Failed to create user account");
}
}
@Get("/me")
@Security("bearerAuth")
@Summary("Get the current authenticated user")
@Description("Returns the profile of the user associated with the provided JWT.")
@Returns(200).Description("User profile returned")
@Returns(401).Description("Missing or invalid token")
async getCurrentUser(@Req() req: any) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
throw new Unauthorized("No token provided");
}
try {
const decoded = jwt.verify(token, JWT_SECRET) as any;
const user = await this.userService.getUserById(decoded.userId);
if (!user) {
throw new Unauthorized("User not found");
}
if (!user.is_active) {
throw new Unauthorized("Account is deactivated");
}
return {
id: user.id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
company_name: user.company_name,
avatar_url: user.avatar_url,
is_active: user.is_active,
last_login_at: user.last_login_at,
email_verified_at: user.email_verified_at,
created_at: user.created_at,
updated_at: user.updated_at
};
} catch (error) {
throw new Unauthorized("Invalid token");
}
}
}

View File

@ -0,0 +1,10 @@
import {Controller} from "@tsed/di";
import {Get} from "@tsed/schema";
@Controller("/hello-world")
export class HelloWorldController {
@Get("/")
get() {
return "hello";
}
}

View File

@ -0,0 +1,501 @@
import { Controller } from "@tsed/di";
import { Post, Get, Delete, Put, Patch, Tags, Summary, Description, Returns, Security } from "@tsed/schema";
import { BodyParams, PathParams } from "@tsed/platform-params";
import { Req } from "@tsed/platform-http";
import { Unauthorized, NotFound } from "@tsed/exceptions";
import jwt from "jsonwebtoken";
import { pool } from "../../config/database.js";
import { UserService } from "../../services/UserService.js";
import { JobService } from "../../services/JobService.js";
import { TokenService } from "../../services/TokenService.js";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
@Controller("/jobs")
@Tags("Jobs")
export class JobController {
private userService = new UserService();
private jobService = new JobService();
private tokenService = new TokenService();
// Middleware to check if user is authenticated
private async checkAuth(req: any) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
throw new Unauthorized("No token provided");
}
try {
const decoded = jwt.verify(token, JWT_SECRET) as any;
const user = await this.userService.getUserById(decoded.userId);
if (!user) {
throw new Unauthorized("User not found");
}
return user;
} catch (error) {
throw new Unauthorized("Invalid token");
}
}
// Create a new job
@Post("/")
@Security("bearerAuth")
@Summary("Create a new job")
@Description("Recruiters and admins can create a job posting.")
@(Returns(200).Description("Job created successfully"))
@(Returns(401).Description("Unauthorized or missing token"))
async createJob(@Req() req: any, @BodyParams() jobData: any) {
try {
console.log('=== JOB CREATION START ===');
console.log('Job creation request received:', JSON.stringify(jobData, null, 2));
console.log('Request headers:', req.headers);
// Test database connection first
try {
const connection = await pool.getConnection();
console.log('Database connection successful');
connection.release();
} catch (dbError) {
console.error('Database connection failed:', dbError);
throw new Error('Database connection failed: ' + dbError.message);
}
const user = await this.checkAuth(req);
console.log('User authenticated:', user.email, user.role);
// Check if user can create a job (basic validation)
if (user.role !== 'recruiter' && user.role !== 'admin') {
throw new Unauthorized("Only recruiters can create jobs");
}
// Validate required fields
if (!jobData.title || !jobData.description || !jobData.requirements) {
throw new Error("Missing required fields: title, description, or requirements");
}
console.log('All validations passed, creating job...');
const createdJob = await this.jobService.createJob(user.id, jobData);
console.log('Job created successfully:', createdJob.id);
return {
success: true,
job: createdJob,
message: "Job created successfully"
};
} catch (error) {
console.error('=== JOB CREATION ERROR ===');
console.error('Error type:', (error as any).constructor.name);
console.error('Error message:', (error as any).message);
console.error('Error stack:', (error as any).stack);
console.error('Full error object:', error as any);
throw error;
}
}
// Test endpoint to check if the controller is working
@Get("/test")
@Summary("Test endpoint")
@Description("Returns a simple heartbeat for Job controller")
@(Returns(200).Description("Service reachable"))
async testEndpoint() {
return {
success: true,
message: "JobController is working!",
timestamp: new Date().toISOString()
};
}
// Get all jobs for a user
@Get("/")
@Security("bearerAuth")
@Summary("List jobs")
@Description("Recruiters see their jobs; admins see all jobs.")
@(Returns(200).Description("Array of jobs returned"))
@(Returns(401).Description("Unauthorized"))
async getJobs(@Req() req: any) {
try {
const user = await this.checkAuth(req);
console.log('Fetching jobs for user:', user.email, user.role);
if (user.role === 'recruiter') {
// Recruiters can only see their own jobs
const jobs = await this.jobService.getJobsByUserId(user.id);
return {
success: true,
jobs: jobs
};
} else if (user.role === 'admin') {
// Admins can see all jobs
const jobs = await this.jobService.getAllJobs();
return {
success: true,
jobs: jobs
};
} else {
throw new Unauthorized("Only recruiters and admins can access jobs");
}
} catch (error: any) {
console.error('Error fetching jobs:', error);
throw error;
}
}
// Get a single job by ID
@Get("/:id")
@Security("bearerAuth")
@Summary("Get a job by ID")
@(Returns(200).Description("Job found"))
@(Returns(401).Description("Unauthorized"))
@(Returns(404).Description("Job not found"))
async getJobById(@Req() req: any, @PathParams("id") id: string) {
try {
const user = await this.checkAuth(req);
console.log('Fetching job by ID:', id, 'for user:', user.email);
const job = await this.jobService.getJobById(id);
if (!job) {
throw new NotFound("Job not found");
}
// Check if user can access this job
if (user.role === 'recruiter' && job.user_id !== user.id) {
throw new Unauthorized("You can only view your own jobs");
}
// Get job links if any
const links = await this.jobService.getJobLinks(id);
return {
success: true,
job: {
...job,
links: links
}
};
} catch (error: any) {
console.error('Error fetching job by ID:', error);
throw error;
}
}
// Update a job (recruiter owns it or admin)
@Put("/:id")
@Security("bearerAuth")
@Summary("Update a job")
@(Returns(200).Description("Job updated"))
@(Returns(401).Description("Unauthorized"))
@(Returns(404).Description("Job not found"))
async updateJob(@Req() req: any, @PathParams("id") id: string, @BodyParams() body: any) {
const user = await this.checkAuth(req);
const job = await this.jobService.getJobById(id);
if (!job) {
throw new NotFound("Job not found");
}
if (user.role === 'recruiter' && job.user_id !== user.id) {
throw new Unauthorized("You can only update your own jobs");
}
const updated = await this.jobService.updateJob(id, body);
return { success: true, job: updated };
}
// Update job status
@Patch("/:id/status")
@Security("bearerAuth")
@Summary("Update job status")
@(Returns(200).Description("Job status updated"))
@(Returns(401).Description("Unauthorized"))
@(Returns(404).Description("Job not found"))
async updateJobStatus(@Req() req: any, @PathParams("id") id: string, @BodyParams() body: { status: string }) {
const user = await this.checkAuth(req);
const job = await this.jobService.getJobById(id);
if (!job) {
throw new NotFound("Job not found");
}
if (user.role === 'recruiter' && job.user_id !== user.id) {
throw new Unauthorized("You can only update your own jobs");
}
const updated = await this.jobService.updateJobStatus(id, body.status);
return { success: true, job: updated };
}
// Create a job link
@Post("/:id/links")
@Security("bearerAuth")
@Summary("Create interview link for a job")
@(Returns(200).Description("Link created"))
@(Returns(401).Description("Unauthorized"))
@(Returns(404).Description("Job not found"))
async createJobLink(@Req() req: any, @PathParams("id") id: string, @BodyParams() linkData: any) {
try {
const user = await this.checkAuth(req);
console.log('Creating job link for job:', id, 'by user:', user.email);
// Verify job exists and user has access
const job = await this.jobService.getJobById(id);
if (!job) {
throw new NotFound("Job not found");
}
if (user.role === 'recruiter' && job.user_id !== user.id) {
throw new Unauthorized("You can only create links for your own jobs");
}
const link = await this.jobService.createJobLink(id, linkData.tokens_available || 0);
return {
success: true,
link: link,
message: "Job link created successfully"
};
} catch (error: any) {
console.error('Error creating job link:', error);
throw error;
}
}
// Add tokens to a job link
@Post("/:id/links/:linkId/tokens")
@Security("bearerAuth")
@Summary("Add tokens to a job link")
@(Returns(200).Description("Tokens added"))
@(Returns(400).Description("Insufficient tokens or invalid amount"))
@(Returns(401).Description("Unauthorized"))
@(Returns(404).Description("Job not found"))
async addTokensToLink(@Req() req: any, @PathParams("id") id: string, @PathParams("linkId") linkId: string, @BodyParams() tokenData: any) {
try {
const user = await this.checkAuth(req);
console.log('Adding tokens to link:', linkId, 'for job:', id, 'by user:', user.email);
// Verify job exists and user has access
const job = await this.jobService.getJobById(id);
if (!job) {
throw new NotFound("Job not found");
}
if (user.role === 'recruiter' && job.user_id !== user.id) {
throw new Unauthorized("You can only modify links for your own jobs");
}
// Check if user has enough tokens
const tokenSummary = await this.tokenService.getUserTokenSummary(user.id);
const tokensToAdd = tokenData.tokens || 0;
if (tokenSummary.total_available < tokensToAdd) {
return {
success: false,
error: "INSUFFICIENT_TOKENS",
message: `You don't have enough tokens. You have ${tokenSummary.total_available} tokens available, but need ${tokensToAdd}.`,
available_tokens: tokenSummary.total_available,
requested_tokens: tokensToAdd
};
}
const updatedLink = await this.jobService.addTokensToLink(linkId, tokensToAdd, user.id);
return {
success: true,
link: updatedLink,
message: "Tokens added successfully"
};
} catch (error: any) {
console.error('Error adding tokens to link:', error);
throw error;
}
}
// Remove tokens from a job link
@Delete("/:id/links/:linkId/tokens")
@Security("bearerAuth")
@Summary("Remove tokens from a job link")
@(Returns(200).Description("Tokens removed"))
@(Returns(400).Description("Invalid amount"))
@(Returns(401).Description("Unauthorized"))
@(Returns(404).Description("Job not found"))
async removeTokensFromLink(@Req() req: any, @PathParams("id") id: string, @PathParams("linkId") linkId: string, @BodyParams() tokenData: any) {
try {
const user = await this.checkAuth(req);
console.log('Removing tokens from link:', linkId, 'for job:', id, 'by user:', user.email);
// Verify job exists and user has access
const job = await this.jobService.getJobById(id);
if (!job) {
throw new NotFound("Job not found");
}
if (user.role === 'recruiter' && job.user_id !== user.id) {
throw new Unauthorized("You can only modify links for your own jobs");
}
const tokensToRemove = tokenData.tokens || 0;
if (tokensToRemove <= 0) {
return {
success: false,
error: "INVALID_AMOUNT",
message: "Please specify a valid number of tokens to remove."
};
}
const updatedLink = await this.jobService.removeTokensFromLink(linkId, tokensToRemove, user.id);
return {
success: true,
link: updatedLink,
message: "Tokens removed successfully"
};
} catch (error: any) {
console.error('Error removing tokens from link:', error);
throw error;
}
}
// Delete a job link
@Delete("/:id/links/:linkId")
@Security("bearerAuth")
@Summary("Delete a job link")
@(Returns(200).Description("Link deleted; tokens returned if applicable"))
@(Returns(401).Description("Unauthorized"))
@(Returns(404).Description("Job not found"))
async deleteJobLink(@Req() req: any, @PathParams("id") id: string, @PathParams("linkId") linkId: string) {
try {
const user = await this.checkAuth(req);
console.log('Deleting job link:', linkId, 'for job:', id, 'by user:', user.email);
// Verify job exists and user has access
const job = await this.jobService.getJobById(id);
if (!job) {
throw new NotFound("Job not found");
}
if (user.role === 'recruiter' && job.user_id !== user.id) {
throw new Unauthorized("You can only modify links for your own jobs");
}
const result = await this.jobService.deleteJobLink(linkId, user.id);
return {
success: true,
message: "Job link deleted successfully",
tokensReturned: result.tokensReturned
};
} catch (error: any) {
console.error('Error deleting job link:', error);
throw error;
}
}
// Get job by interview link (public endpoint)
@Get("/interview/:linkId")
@Summary("Get job by interview link")
@Description("Public endpoint used by candidates to load interview context.")
@(Returns(200).Description("Job returned"))
@(Returns(404).Description("Interview link not found or expired"))
async getJobByLink(@PathParams("linkId") linkId: string) {
try {
console.log('Getting job by link ID:', linkId);
const job = await this.jobService.getJobByLinkId(linkId);
if (!job) {
throw new NotFound("Interview link not found or expired");
}
return {
success: true,
job: job
};
} catch (error: any) {
console.error('Error getting job by link:', error);
throw error;
}
}
// Submit interview responses
@Post("/interview/:linkId/submit")
@Summary("Submit interview responses")
@Description("Submits candidate answers; if not a test, consumes one token.")
@(Returns(200).Description("Submission acknowledged"))
@(Returns(404).Description("Interview link not found or expired"))
async submitInterview(@PathParams("linkId") linkId: string, @BodyParams() submissionData: any) {
try {
console.log('Submitting interview for link:', linkId);
const job = await this.jobService.getJobByLinkId(linkId);
if (!job) {
throw new NotFound("Interview link not found or expired");
}
// If it's not a test, save the interview
if (!submissionData.isTest) {
await this.jobService.submitInterview(linkId, submissionData.answers);
}
return {
success: true,
message: submissionData.isTest ? "Test interview completed" : "Interview submitted successfully"
};
} catch (error: any) {
console.error('Error submitting interview:', error);
throw error;
}
}
// Log failed interview attempt (consent declined)
@Post("/interview/:linkId/failed")
@Summary("Log a failed interview attempt")
@Description("Records consent decline or early exit without consuming tokens.")
@(Returns(200).Description("Event recorded"))
@(Returns(404).Description("Interview link not found or expired"))
async logFailedAttempt(@PathParams("linkId") linkId: string) {
try {
console.log('Logging failed attempt for link:', linkId);
const job = await this.jobService.getJobByLinkId(linkId);
if (!job) {
throw new NotFound("Interview link not found or expired");
}
// Log the failed attempt (no token deduction)
await this.jobService.logFailedAttempt(linkId);
return {
success: true,
message: "Failed attempt logged successfully"
};
} catch (error: any) {
console.error('Error logging failed attempt:', error);
throw error;
}
}
// Health check endpoint
@Get("/health")
@Summary("Health check")
@Description("Reports DB connectivity and service health")
@(Returns(200).Description("Healthy or unhealthy status returned"))
async healthCheck() {
try {
// Test database connection
const connection = await pool.getConnection();
connection.release();
return {
status: "healthy",
database: "connected",
timestamp: new Date().toISOString()
};
} catch (error) {
return {
status: "unhealthy",
database: "disconnected",
error: (error as any).message,
timestamp: new Date().toISOString()
};
}
}
}

View File

@ -0,0 +1,75 @@
import { Controller } from "@tsed/di";
import { Get, Summary, Description, Returns, Tags, Security } from "@tsed/schema";
import { Req } from "@tsed/platform-http";
import { Unauthorized } from "@tsed/exceptions";
import jwt from "jsonwebtoken";
import { UserService } from "../../services/UserService.js";
import { TokenService } from "../../services/TokenService.js";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
@Controller("/user")
@Tags("Users")
export class UserController {
private userService = new UserService();
private tokenService = new TokenService();
// Middleware to check if user is authenticated
private async checkAuth(req: any) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
throw new Unauthorized("No token provided");
}
try {
const decoded = jwt.verify(token, JWT_SECRET) as any;
const user = await this.userService.getUserById(decoded.userId);
if (!user) {
throw new Unauthorized("User not found");
}
return user;
} catch (error) {
throw new Unauthorized("Invalid token");
}
}
// Get user token summary
@Get("/token-summary")
@Security("bearerAuth")
@Summary("Get token summary for current user")
@Description("Returns total tokens purchased and used by the authenticated user.")
@Returns(200).Description("Token summary returned")
@Returns(401).Description("Unauthorized")
async getTokenSummary(@Req() req: any) {
const user = await this.checkAuth(req);
return await this.tokenService.getUserTokenSummary(user.id);
}
// Get user profile
@Get("/profile")
@Security("bearerAuth")
@Summary("Get current user profile")
@Description("Returns profile details for the authenticated user")
@Returns(200).Description("User profile returned")
@Returns(401).Description("Unauthorized")
async getProfile(@Req() req: any) {
const user = await this.checkAuth(req);
return {
id: user.id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
company_name: user.company_name,
avatar_url: user.avatar_url,
is_active: user.is_active,
last_login_at: user.last_login_at,
email_verified_at: user.email_verified_at,
created_at: user.created_at,
updated_at: user.updated_at
};
}
}

View File

@ -0,0 +1,9 @@
/**
* @file Automatically generated by @tsed/barrels.
*/
export * from "./AIController.js";
export * from "./AdminController.js";
export * from "./AuthController.js";
export * from "./HelloWorldController.js";
export * from "./JobController.js";
export * from "./UserController.js";

36
backend/src/index.ts Normal file
View File

@ -0,0 +1,36 @@
import {$log} from "@tsed/logger";
import { PlatformExpress } from "@tsed/platform-express";
import {Server} from "./Server.js";
const SIG_EVENTS = [
"beforeExit",
"SIGHUP",
"SIGINT",
"SIGQUIT",
"SIGILL",
"SIGTRAP",
"SIGABRT",
"SIGBUS",
"SIGFPE",
"SIGUSR1",
"SIGSEGV",
"SIGUSR2",
"SIGTERM"
];
try {
const platform = await PlatformExpress.bootstrap(Server);
await platform.listen();
SIG_EVENTS.forEach((evt) => process.on(evt, () => platform.stop()));
["uncaughtException", "unhandledRejection"].forEach((evt) =>
process.on(evt, async (error) => {
$log.error({event: "SERVER_" + evt.toUpperCase(), message: error.message, stack: error.stack});
await platform.stop();
})
);
} catch (error) {
$log.error({event: "SERVER_BOOTSTRAP_ERROR", message: error.message, stack: error.stack});
}

View File

@ -0,0 +1,51 @@
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { UserService } from "../services/UserService.js";
import { Unauthorized } from "@tsed/exceptions";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
export interface AuthenticatedRequest extends Request {
user?: {
id: string;
email: string;
role: string;
first_name: string;
last_name: string;
};
}
export async function adminAuth(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
throw new Unauthorized("No token provided");
}
const decoded = jwt.verify(token, JWT_SECRET) as any;
const userService = new UserService();
const user = await userService.getUserById(decoded.userId);
if (!user) {
throw new Unauthorized("User not found");
}
if (user.role !== 'admin') {
throw new Unauthorized("Admin access required");
}
// Add user info to request
req.user = {
id: user.id,
email: user.email,
role: user.role,
first_name: user.first_name,
last_name: user.last_name
};
next();
} catch (error) {
next(new Unauthorized("Invalid token or insufficient permissions"));
}
}

View File

@ -0,0 +1,66 @@
export interface User {
id: string;
email: string;
password_hash: string;
first_name: string;
last_name: string;
role: 'admin' | 'recruiter';
company_name?: string;
avatar_url?: string;
is_active: boolean;
last_login_at?: Date;
email_verified_at?: Date;
created_at: Date;
updated_at: Date;
deleted_at?: Date;
}
export type LoginRequest = {
email: string;
password: string;
}
export type RegisterRequest = {
email: string;
password: string;
first_name: string;
last_name: string;
company_name?: string;
}
export type CreateUserRequest = {
email: string;
password: string;
first_name: string;
last_name: string;
company_name?: string;
role?: 'admin' | 'recruiter';
}
export type UpdateUserRequest = {
first_name?: string;
last_name?: string;
company_name?: string;
avatar_url?: string;
is_active?: boolean;
}
export type UserResponse = {
id: string;
email: string;
first_name: string;
last_name: string;
role: 'admin' | 'recruiter';
company_name?: string;
avatar_url?: string;
is_active: boolean;
last_login_at?: Date;
email_verified_at?: Date;
created_at: Date;
updated_at: Date;
}
export type LoginResponse = {
token: string;
user: UserResponse;
}

View File

@ -0,0 +1,294 @@
import axios from 'axios';
import { ChatbotService } from './ChatbotService.js';
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface ChatRequest {
model: string;
messages: ChatMessage[];
temperature: number;
}
export interface ChatChoice {
message: ChatMessage;
}
export interface ChatResponse {
choices: ChatChoice[];
}
export class AIService {
private apiKey: string;
private model: string;
private baseUrl: string;
private relPath: string;
private temperature: number;
private chatbotService: ChatbotService;
// Predefined models from your C# code
private static readonly PREDEFINED_MODELS: Record<string, string> = {
'dobby': 'sentientagi/dobby-mini-unhinged-plus-llama-3.1-8b',
'dolphin': 'cognitivecomputations/dolphin-mixtral-8x22b',
'dolphin_free': 'cognitivecomputations/dolphin3.0-mistral-24b:free',
'gemma': 'google/gemma-3-12b-it',
'gpt-4o-mini': 'openai/gpt-4o-mini',
'gpt-4.1-nano': 'openai/gpt-4.1-nano',
'qwen': 'qwen/qwen3-30b-a3b',
'unslop': 'thedrummer/unslopnemo-12b',
'euryale': 'sao10k/l3.3-euryale-70b',
'wizard': 'microsoft/wizardlm-2-8x22b',
'deepseek': 'deepseek/deepseek-chat-v3-0324'
};
constructor() {
this.apiKey = process.env.OPENROUTER_API_KEY || 'sk-or-REPLACE_ME';
this.model = process.env.OPENROUTER_MODEL || 'gemma';
this.baseUrl = process.env.OPENROUTER_BASE_URL || 'openrouter.ai';
this.relPath = process.env.OPENROUTER_REL_PATH || '/api';
this.temperature = parseFloat(process.env.OPENROUTER_TEMPERATURE || '0.7');
this.chatbotService = new ChatbotService();
// Map predefined model names to full model names
if (AIService.PREDEFINED_MODELS[this.model]) {
this.model = AIService.PREDEFINED_MODELS[this.model];
}
console.log(`[DEBUG] AIService initialized:`);
console.log(`[DEBUG] - API Key: ${this.apiKey.substring(0, 10)}...`);
console.log(`[DEBUG] - Model: ${this.model}`);
console.log(`[DEBUG] - Base URL: ${this.baseUrl}`);
console.log(`[DEBUG] - Rel Path: ${this.relPath}`);
console.log(`[DEBUG] - Temperature: ${this.temperature}`);
console.log(`[DEBUG] - Chatbot Service: ${this.chatbotService ? 'Enabled' : 'Disabled'}`);
}
async generateResponse(prompt: string, systemMessage?: string): Promise<string | null> {
try {
const messages: ChatMessage[] = [];
if (systemMessage) {
messages.push({ role: 'system', content: systemMessage });
}
messages.push({ role: 'user', content: prompt });
const payload: ChatRequest = {
model: this.model,
messages: messages,
temperature: this.temperature
};
const url = `https://${this.baseUrl}${this.relPath}/v1/chat/completions`;
console.log(`[DEBUG] Sending to OpenRouter - Model: ${this.model}, URL: ${url}`);
console.log(`[DEBUG] Prompt length: ${prompt.length} characters`);
const response = await axios.post(url, payload, {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
const data = response.data as ChatResponse;
const aiResponse = data.choices?.[0]?.message?.content || null;
console.log(`[DEBUG] OpenRouter Response: ${aiResponse}`);
return aiResponse;
} catch (error) {
console.error('Error calling OpenRouter:', error);
if (error.response) {
console.error('Response status:', error.response.status);
console.error('Response data:', error.response.data);
}
return null;
}
}
async generateResponseWithHistory(
userMessage: string,
conversationHistory: any[],
systemMessage?: string
): Promise<string | null> {
try {
const messages: ChatMessage[] = [];
if (systemMessage) {
messages.push({ role: 'system', content: systemMessage });
}
// Add conversation history
conversationHistory.forEach(msg => {
if (msg.sender === 'candidate' || msg.sender === 'user') {
messages.push({ role: 'user', content: msg.message });
} else if (msg.sender === 'ai' || msg.sender === 'assistant') {
messages.push({ role: 'assistant', content: msg.message });
}
});
// Add current user message
messages.push({ role: 'user', content: userMessage });
const payload: ChatRequest = {
model: this.model,
messages: messages,
temperature: this.temperature
};
const url = `https://${this.baseUrl}${this.relPath}/v1/chat/completions`;
console.log(`[DEBUG] Sending to OpenRouter with history - Model: ${this.model}`);
console.log(`[DEBUG] Messages count: ${messages.length}`);
const response = await axios.post(url, payload, {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
const data = response.data as ChatResponse;
const aiResponse = data.choices?.[0]?.message?.content || null;
console.log(`[DEBUG] OpenRouter Response: ${aiResponse}`);
return aiResponse;
} catch (error) {
console.error('Error calling OpenRouter with history:', error);
if (error.response) {
console.error('Response status:', error.response.status);
console.error('Response data:', error.response.data);
}
return null;
}
}
/**
* Generate response using chatbot service with fallback to direct OpenRouter
*/
async generateResponseWithChatbot(
userMessage: string,
conversationHistory: any[],
systemMessage?: string,
job?: any,
candidateName?: string,
linkId?: string
): Promise<string | null> {
// Try chatbot service first
try {
const isHealthy = await this.chatbotService.isHealthy();
if (isHealthy) {
console.log(`[DEBUG] Using chatbot service for response generation`);
const response = await this.chatbotService.sendMessage({
message: userMessage,
conversationHistory,
systemMessage,
job,
candidateName,
linkId
});
if (response) {
return response;
}
}
} catch (error) {
console.error('[ERROR] Chatbot service failed, falling back to direct OpenRouter:', error);
}
// Fallback to direct OpenRouter
if (this.chatbotService.shouldUseFallback()) {
console.log(`[DEBUG] Falling back to direct OpenRouter`);
return await this.generateResponseWithHistory(userMessage, conversationHistory, systemMessage);
}
return null;
}
/**
* Initialize interview using chatbot service
*/
async initializeInterviewWithChatbot(
job: any,
candidateName: string,
linkId: string,
conversationHistory: any[] = []
): Promise<string | null> {
try {
const isHealthy = await this.chatbotService.isHealthy();
if (isHealthy) {
console.log(`[DEBUG] Using chatbot service for interview initialization`);
return await this.chatbotService.initializeInterview(job, candidateName, linkId, conversationHistory);
}
} catch (error) {
console.error('[ERROR] Chatbot service failed for interview initialization:', error);
}
// Fallback to direct OpenRouter
if (this.chatbotService.shouldUseFallback()) {
console.log(`[DEBUG] Falling back to direct OpenRouter for interview initialization`);
const systemMessage = this.buildInterviewSystemMessage(job, candidateName, conversationHistory);
return await this.generateResponse(`The candidate's name is ${candidateName}. Please start the interview.`, systemMessage);
}
return null;
}
/**
* End interview using chatbot service
*/
async endInterviewWithChatbot(linkId: string): Promise<boolean> {
try {
const isHealthy = await this.chatbotService.isHealthy();
if (isHealthy) {
console.log(`[DEBUG] Using chatbot service for interview end`);
return await this.chatbotService.endInterview(linkId);
}
} catch (error) {
console.error('[ERROR] Chatbot service failed for interview end:', error);
}
return false;
}
/**
* Build interview system message
*/
private buildInterviewSystemMessage(job: any, candidateName: string, conversationHistory: any[] = []): string {
const skills = job.skills_required ? job.skills_required.join(', ') : 'various technical skills';
const experience = job.experience_level.replace('_', ' ');
// Build context from conversation history (mandatory question answers)
const conversationContext = conversationHistory
.map(msg => `${msg.sender === 'candidate' ? 'Candidate' : 'Interviewer'}: ${msg.message}`)
.join('\n');
return `You are an AI interview agent conducting an interview for the position: ${job.title}
Job Description: ${job.description}
Requirements: ${job.requirements}
Required Skills: ${skills}
Experience Level: ${experience}
Location: ${job.location || 'Remote'}
${conversationContext ? `Previous conversation (mandatory questions answered):
${conversationContext}
Based on the candidate's answers to the mandatory questions above, you should now conduct a deeper interview.` : ''}
Your task is to:
1. Greet the candidate warmly and professionally
2. Introduce yourself as their evaluation agent
3. ${conversationContext ? 'Acknowledge their previous answers and build upon them' : 'Explain that you\'ll be conducting a comprehensive interview'}
4. Ask them to tell you about themselves and their interest in this role
5. Keep your response conversational and engaging
6. Don't ask multiple questions at once - start with one open-ended question
Respond in a friendly, professional tone. Keep it concise but welcoming.`;
}
}

View File

@ -0,0 +1,776 @@
import { pool } from '../config/database.js';
import { $log } from '@tsed/logger';
import bcrypt from 'bcryptjs';
import { randomUUID } from 'crypto';
export class AdminService {
// System Statistics
async getSystemStatistics() {
const connection = await pool.getConnection();
try {
// Get basic counts
const [userStats] = await connection.execute(`
SELECT
COUNT(*) as total_users,
SUM(CASE WHEN is_active = TRUE THEN 1 ELSE 0 END) as active_users
FROM users
WHERE deleted_at IS NULL
`);
const [jobStats] = await connection.execute(`
SELECT COUNT(*) as total_jobs
FROM jobs
WHERE deleted_at IS NULL
`);
const [interviewStats] = await connection.execute(`
SELECT COUNT(*) as total_interviews
FROM interviews
WHERE status = 'completed'
`);
const [tokenStats] = await connection.execute(`
SELECT
COALESCE(SUM(quantity), 0) as total_tokens_purchased,
COALESCE(SUM(tokens_used), 0) as total_tokens_used
FROM interview_tokens
`);
const [revenueStats] = await connection.execute(`
SELECT COALESCE(SUM(amount), 0) as total_revenue
FROM payment_records
WHERE status = 'paid'
`);
const userStatsData = Array.isArray(userStats) ? userStats[0] : userStats;
const jobStatsData = Array.isArray(jobStats) ? jobStats[0] : jobStats;
const interviewStatsData = Array.isArray(interviewStats) ? interviewStats[0] : interviewStats;
const tokenStatsData = Array.isArray(tokenStats) ? tokenStats[0] : tokenStats;
const revenueStatsData = Array.isArray(revenueStats) ? revenueStats[0] : revenueStats;
return {
total_users: userStatsData?.total_users || 0,
active_users: userStatsData?.active_users || 0,
total_jobs: jobStatsData?.total_jobs || 0,
total_interviews: interviewStatsData?.total_interviews || 0,
total_tokens_purchased: tokenStatsData?.total_tokens_purchased || 0,
total_tokens_used: tokenStatsData?.total_tokens_used || 0,
total_revenue: revenueStatsData?.total_revenue || 0,
generated_at: new Date().toISOString()
};
} catch (error) {
$log.error('Error getting system statistics:', error);
throw error;
} finally {
connection.release();
}
}
// User Management
async getAllUsers() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
id, email, first_name, last_name, role, company_name,
avatar_url, is_active, last_login_at, email_verified_at,
created_at, updated_at
FROM users
WHERE deleted_at IS NULL
ORDER BY created_at DESC
`);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting all users:', error);
throw error;
} finally {
connection.release();
}
}
async getUserById(id: string) {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
id, email, first_name, last_name, role, company_name,
avatar_url, is_active, last_login_at, email_verified_at,
created_at, updated_at
FROM users
WHERE id = ? AND deleted_at IS NULL
`, [id]);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0];
}
return null;
} catch (error) {
$log.error('Error getting user by ID:', error);
throw error;
} finally {
connection.release();
}
}
async updateUser(id: string, userData: any) {
const connection = await pool.getConnection();
try {
const updateFields = [];
const values = [];
if (userData.first_name) {
updateFields.push('first_name = ?');
values.push(userData.first_name);
}
if (userData.last_name) {
updateFields.push('last_name = ?');
values.push(userData.last_name);
}
if (userData.email) {
updateFields.push('email = ?');
values.push(userData.email);
}
if (userData.role) {
updateFields.push('role = ?');
values.push(userData.role);
}
if (userData.company_name !== undefined) {
updateFields.push('company_name = ?');
values.push(userData.company_name);
}
if (userData.avatar_url !== undefined) {
updateFields.push('avatar_url = ?');
values.push(userData.avatar_url);
}
if (userData.is_active !== undefined) {
updateFields.push('is_active = ?');
values.push(userData.is_active);
}
if (updateFields.length === 0) {
throw new Error('No fields to update');
}
updateFields.push('updated_at = NOW()');
values.push(id);
await connection.execute(
`UPDATE users SET ${updateFields.join(', ')} WHERE id = ? AND deleted_at IS NULL`,
values
);
return await this.getUserById(id);
} catch (error) {
$log.error('Error updating user:', error);
throw error;
} finally {
connection.release();
}
}
async toggleUserStatus(id: string) {
const connection = await pool.getConnection();
try {
// Get current status
const [rows] = await connection.execute(
'SELECT is_active FROM users WHERE id = ? AND deleted_at IS NULL',
[id]
);
if (Array.isArray(rows) && rows.length === 0) {
throw new Error('User not found');
}
const currentStatus = Array.isArray(rows) ? rows[0] : rows;
const newStatus = !currentStatus.is_active;
await connection.execute(
'UPDATE users SET is_active = ?, updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
[newStatus, id]
);
return { success: true, new_status: newStatus };
} catch (error) {
$log.error('Error toggling user status:', error);
throw error;
} finally {
connection.release();
}
}
async changeUserPassword(id: string, newPassword: string) {
const connection = await pool.getConnection();
try {
const password_hash = await bcrypt.hash(newPassword, 10);
await connection.execute(
'UPDATE users SET password_hash = ?, updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
[password_hash, id]
);
return { success: true };
} catch (error) {
$log.error('Error changing user password:', error);
throw error;
} finally {
connection.release();
}
}
async createUser(userData: any) {
const connection = await pool.getConnection();
try {
// Check if user already exists
const [existingUsers] = await connection.execute(
'SELECT id FROM users WHERE email = ? AND deleted_at IS NULL',
[userData.email]
);
if (Array.isArray(existingUsers) && existingUsers.length > 0) {
throw new Error('User with this email already exists');
}
// Hash password
const password_hash = await bcrypt.hash(userData.password, 10);
// Generate UUID for user ID
const userId = randomUUID();
// Insert user
await connection.execute(
`INSERT INTO users (id, email, password_hash, first_name, last_name, role, company_name, is_active, email_verified_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW(), NOW())`,
[
userId,
userData.email,
password_hash,
userData.first_name,
userData.last_name,
userData.role || 'recruiter',
userData.company_name || null,
true
]
);
// Initialize usage tracking
await connection.execute(
'INSERT INTO user_usage (user_id) VALUES (?)',
[userId]
);
return await this.getUserById(userId);
} catch (error) {
$log.error('Error creating user:', error);
throw error;
} finally {
connection.release();
}
}
// Job Management
async getAllJobs() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
j.*,
u.first_name,
u.last_name,
u.email,
u.company_name
FROM jobs j
LEFT JOIN users u ON j.user_id = u.id
WHERE j.deleted_at IS NULL
ORDER BY j.created_at DESC
`);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting all jobs:', error);
throw error;
} finally {
connection.release();
}
}
async getJobById(id: string) {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
j.*,
u.first_name,
u.last_name,
u.email,
u.company_name
FROM jobs j
LEFT JOIN users u ON j.user_id = u.id
WHERE j.id = ? AND j.deleted_at IS NULL
`, [id]);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0];
}
return null;
} catch (error) {
$log.error('Error getting job by ID:', error);
throw error;
} finally {
connection.release();
}
}
async updateJobStatus(id: string, status: string) {
const connection = await pool.getConnection();
try {
await connection.execute(
'UPDATE jobs SET status = ?, updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
[status, id]
);
return { success: true, new_status: status };
} catch (error) {
$log.error('Error updating job status:', error);
throw error;
} finally {
connection.release();
}
}
async updateJob(id: string, jobData: any) {
const connection = await pool.getConnection();
try {
const updateFields = [];
const values = [];
if (jobData.title) {
updateFields.push('title = ?');
values.push(jobData.title);
}
if (jobData.description) {
updateFields.push('description = ?');
values.push(jobData.description);
}
if (jobData.requirements) {
updateFields.push('requirements = ?');
values.push(jobData.requirements);
}
if (jobData.skills_required) {
updateFields.push('skills_required = ?');
values.push(JSON.stringify(jobData.skills_required));
}
if (jobData.location) {
updateFields.push('location = ?');
values.push(jobData.location);
}
if (jobData.employment_type) {
updateFields.push('employment_type = ?');
values.push(jobData.employment_type);
}
if (jobData.experience_level) {
updateFields.push('experience_level = ?');
values.push(jobData.experience_level);
}
if (jobData.salary_min !== undefined) {
updateFields.push('salary_min = ?');
values.push(jobData.salary_min);
}
if (jobData.salary_max !== undefined) {
updateFields.push('salary_max = ?');
values.push(jobData.salary_max);
}
if (jobData.currency) {
updateFields.push('currency = ?');
values.push(jobData.currency);
}
if (jobData.status) {
updateFields.push('status = ?');
values.push(jobData.status);
}
if (updateFields.length === 0) {
throw new Error('No fields to update');
}
updateFields.push('updated_at = NOW()');
values.push(id);
await connection.execute(
`UPDATE jobs SET ${updateFields.join(', ')} WHERE id = ? AND deleted_at IS NULL`,
values
);
return await this.getJobById(id);
} catch (error) {
$log.error('Error updating job:', error);
throw error;
} finally {
connection.release();
}
}
// Token Management
async getUserTokenSummaries() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
u.id as user_id,
u.first_name,
u.last_name,
u.email,
COALESCE(SUM(it.quantity), 0) as total_purchased,
COALESCE(SUM(it.tokens_used), 0) as total_used,
COALESCE(SUM(it.tokens_remaining), 0) as total_available,
CASE
WHEN SUM(it.quantity) > 0 THEN ROUND((SUM(it.tokens_used) / SUM(it.quantity)) * 100, 2)
ELSE 0
END as utilization_percentage
FROM users u
LEFT JOIN interview_tokens it ON u.id = it.user_id AND it.status = 'active'
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.first_name, u.last_name, u.email
ORDER BY u.created_at DESC
`);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting user token summaries:', error);
throw error;
} finally {
connection.release();
}
}
async addTokensToUser(tokenData: any) {
const connection = await pool.getConnection();
try {
const { user_id, quantity, price_per_token } = tokenData;
const total_price = quantity * price_per_token;
const tokenId = randomUUID();
// Create token record
await connection.execute(`
INSERT INTO interview_tokens (
id, user_id, token_type, quantity, price_per_token,
total_price, status, purchased_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'active', NOW(), NOW(), NOW())
`, [
tokenId,
user_id,
quantity === 1 ? 'single' : 'bulk',
quantity,
price_per_token,
total_price
]);
// No payment record needed for admin-granted tokens
// Update user usage
await connection.execute(`
INSERT INTO user_usage (user_id, tokens_purchased)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE tokens_purchased = tokens_purchased + ?
`, [user_id, quantity, quantity]);
return { success: true, token_id: tokenId };
} catch (error) {
$log.error('Error adding tokens to user:', error);
throw error;
} finally {
connection.release();
}
}
// Token Packages
async getTokenPackages() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT * FROM token_packages
ORDER BY created_at DESC
`);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting token packages:', error);
throw error;
} finally {
connection.release();
}
}
async createTokenPackage(packageData: any) {
const connection = await pool.getConnection();
try {
const packageId = randomUUID();
await connection.execute(`
INSERT INTO token_packages (
id, name, description, quantity, price_per_token,
total_price, discount_percentage, is_popular, is_active,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
`, [
packageId,
packageData.name,
packageData.description,
packageData.quantity,
packageData.price_per_token,
packageData.total_price,
packageData.discount_percentage || 0,
packageData.is_popular || false,
packageData.is_active !== false
]);
return { success: true, package_id: packageId };
} catch (error) {
$log.error('Error creating token package:', error);
throw error;
} finally {
connection.release();
}
}
async updateTokenPackage(id: string, packageData: any) {
const connection = await pool.getConnection();
try {
const updateFields = [];
const values = [];
if (packageData.name) {
updateFields.push('name = ?');
values.push(packageData.name);
}
if (packageData.description) {
updateFields.push('description = ?');
values.push(packageData.description);
}
if (packageData.quantity) {
updateFields.push('quantity = ?');
values.push(packageData.quantity);
}
if (packageData.price_per_token) {
updateFields.push('price_per_token = ?');
values.push(packageData.price_per_token);
}
if (packageData.total_price) {
updateFields.push('total_price = ?');
values.push(packageData.total_price);
}
if (packageData.discount_percentage !== undefined) {
updateFields.push('discount_percentage = ?');
values.push(packageData.discount_percentage);
}
if (packageData.is_popular !== undefined) {
updateFields.push('is_popular = ?');
values.push(packageData.is_popular);
}
if (packageData.is_active !== undefined) {
updateFields.push('is_active = ?');
values.push(packageData.is_active);
}
if (updateFields.length === 0) {
throw new Error('No fields to update');
}
updateFields.push('updated_at = NOW()');
values.push(id);
await connection.execute(
`UPDATE token_packages SET ${updateFields.join(', ')} WHERE id = ?`,
values
);
return { success: true };
} catch (error) {
$log.error('Error updating token package:', error);
throw error;
} finally {
connection.release();
}
}
async toggleTokenPackageStatus(id: string) {
const connection = await pool.getConnection();
try {
// Get current status
const [rows] = await connection.execute(
'SELECT is_active FROM token_packages WHERE id = ?',
[id]
);
if (Array.isArray(rows) && rows.length === 0) {
throw new Error('Token package not found');
}
const currentStatus = Array.isArray(rows) ? rows[0] : rows;
const newStatus = !currentStatus.is_active;
await connection.execute(
'UPDATE token_packages SET is_active = ?, updated_at = NOW() WHERE id = ?',
[newStatus, id]
);
return { success: true, new_status: newStatus };
} catch (error) {
$log.error('Error toggling token package status:', error);
throw error;
} finally {
connection.release();
}
}
async deleteTokenPackage(id: string) {
const connection = await pool.getConnection();
try {
await connection.execute(
'DELETE FROM token_packages WHERE id = ?',
[id]
);
return { success: true };
} catch (error) {
$log.error('Error deleting token package:', error);
throw error;
} finally {
connection.release();
}
}
// Interview Management
async getAllInterviews() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
i.*,
u.first_name,
u.last_name,
u.email,
j.title as job_title
FROM interviews i
LEFT JOIN users u ON i.user_id = u.id
LEFT JOIN jobs j ON i.job_id = j.id
ORDER BY i.created_at DESC
`);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting all interviews:', error);
throw error;
} finally {
connection.release();
}
}
async getInterviewById(id: string) {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
i.*,
u.first_name,
u.last_name,
u.email,
j.title as job_title
FROM interviews i
LEFT JOIN users u ON i.user_id = u.id
LEFT JOIN jobs j ON i.job_id = j.id
WHERE i.id = ?
`, [id]);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0];
}
return null;
} catch (error) {
$log.error('Error getting interview by ID:', error);
throw error;
} finally {
connection.release();
}
}
// Payment Records
async getPaymentRecords() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
pr.*,
u.first_name,
u.last_name,
u.email,
tp.name as package_name
FROM payment_records pr
LEFT JOIN users u ON pr.user_id = u.id
LEFT JOIN token_packages tp ON pr.token_package_id = tp.id
ORDER BY pr.created_at DESC
`);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting payment records:', error);
throw error;
} finally {
connection.release();
}
}
async getPaymentById(id: string) {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
pr.*,
u.first_name,
u.last_name,
u.email,
tp.name as package_name
FROM payment_records pr
LEFT JOIN users u ON pr.user_id = u.id
LEFT JOIN token_packages tp ON pr.token_package_id = tp.id
WHERE pr.id = ?
`, [id]);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0];
}
return null;
} catch (error) {
$log.error('Error getting payment by ID:', error);
throw error;
} finally {
connection.release();
}
}
}

View File

@ -0,0 +1,170 @@
import axios, { AxiosInstance, AxiosResponse } from 'axios';
export interface ChatbotRequest {
message: string;
conversationHistory?: any[];
job?: any;
candidateName?: string;
linkId?: string;
systemMessage?: string;
}
export interface ChatbotResponse {
ok: boolean;
reply?: string;
error?: string;
}
export interface ChatbotHealthResponse {
status: string;
timestamp: string;
}
export class ChatbotService {
private client: AxiosInstance;
private baseUrl: string;
private timeout: number;
private fallbackEnabled: boolean;
constructor() {
this.baseUrl = process.env.CHATBOT_SERVICE_URL || 'http://chatbot:80';
this.timeout = parseInt(process.env.CHATBOT_SERVICE_TIMEOUT || '30000');
this.fallbackEnabled = process.env.CHATBOT_FALLBACK_ENABLED === 'true';
this.client = axios.create({
baseURL: this.baseUrl,
timeout: this.timeout,
headers: {
'Content-Type': 'application/json',
},
});
console.log(`[DEBUG] ChatbotService initialized:`);
console.log(`[DEBUG] - Base URL: ${this.baseUrl}`);
console.log(`[DEBUG] - Timeout: ${this.timeout}ms`);
console.log(`[DEBUG] - Fallback Enabled: ${this.fallbackEnabled}`);
}
/**
* Check if chatbot service is healthy
*/
async isHealthy(): Promise<boolean> {
try {
const response = await this.client.get('/api/health');
return response.status === 200;
} catch (error) {
console.error('[ERROR] Chatbot service health check failed:', error);
return false;
}
}
/**
* Send a chat message to the chatbot service
*/
async sendMessage(request: ChatbotRequest): Promise<string | null> {
try {
console.log(`[DEBUG] Sending message to chatbot service: ${request.message.substring(0, 100)}...`);
const response: AxiosResponse<ChatbotResponse> = await this.client.post('/api/chat', {
message: request.message,
conversationHistory: request.conversationHistory,
job: request.job,
candidateName: request.candidateName,
linkId: request.linkId,
systemMessage: request.systemMessage
});
if (response.data.ok && response.data.reply) {
console.log(`[DEBUG] Chatbot service response received: ${response.data.reply.substring(0, 100)}...`);
return response.data.reply;
} else {
console.error('[ERROR] Chatbot service returned error:', response.data.error);
return null;
}
} catch (error) {
console.error('[ERROR] Chatbot service request failed:', error);
if (error.response) {
console.error('[ERROR] Response status:', error.response.status);
console.error('[ERROR] Response data:', error.response.data);
}
return null;
}
}
/**
* Initialize an interview with the chatbot service
*/
async initializeInterview(job: any, candidateName: string, linkId: string, conversationHistory: any[] = []): Promise<string | null> {
try {
console.log(`[DEBUG] Initializing interview with chatbot service for ${candidateName}`);
const response: AxiosResponse<ChatbotResponse> = await this.client.post('/api/interview/start', {
job,
candidateName,
linkId,
conversationHistory
});
if (response.data.ok && response.data.reply) {
console.log(`[DEBUG] Interview initialized successfully`);
return response.data.reply;
} else {
console.error('[ERROR] Failed to initialize interview:', response.data.error);
return null;
}
} catch (error) {
console.error('[ERROR] Interview initialization failed:', error);
return null;
}
}
/**
* End an interview with the chatbot service
*/
async endInterview(linkId: string): Promise<boolean> {
try {
console.log(`[DEBUG] Ending interview with chatbot service for linkId: ${linkId}`);
const response: AxiosResponse<ChatbotResponse> = await this.client.post('/api/interview/end', {
linkId
});
if (response.data.ok) {
console.log(`[DEBUG] Interview ended successfully`);
return true;
} else {
console.error('[ERROR] Failed to end interview:', response.data.error);
return false;
}
} catch (error) {
console.error('[ERROR] Interview end failed:', error);
return false;
}
}
/**
* Get interview status from chatbot service
*/
async getInterviewStatus(linkId: string): Promise<any | null> {
try {
const response: AxiosResponse<ChatbotResponse> = await this.client.get(`/api/interview/status/${linkId}`);
if (response.data.ok) {
return response.data;
} else {
console.error('[ERROR] Failed to get interview status:', response.data.error);
return null;
}
} catch (error) {
console.error('[ERROR] Get interview status failed:', error);
return null;
}
}
/**
* Check if fallback to direct AI service should be used
*/
shouldUseFallback(): boolean {
return this.fallbackEnabled;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,443 @@
import { pool } from '../config/database.js';
import { $log } from '@tsed/logger';
import { randomUUID } from 'crypto';
export interface InterviewToken {
id: string;
user_id: string;
token_type: 'single' | 'bulk';
quantity: number;
price_per_token: number;
total_price: number;
tokens_used: number;
tokens_remaining: number;
status: 'active' | 'exhausted' | 'expired';
expires_at?: string;
purchased_at: string;
created_at: string;
updated_at: string;
}
export interface TokenPackage {
id: string;
name: string;
description: string;
quantity: number;
price_per_token: number;
total_price: number;
discount_percentage: number;
is_popular: boolean;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateTokenPackageRequest {
name: string;
description: string;
quantity: number;
price_per_token: number;
total_price: number;
discount_percentage?: number;
is_popular?: boolean;
is_active?: boolean;
}
export interface UpdateTokenPackageRequest {
name?: string;
description?: string;
quantity?: number;
price_per_token?: number;
total_price?: number;
discount_percentage?: number;
is_popular?: boolean;
is_active?: boolean;
}
export class TokenService {
// Token Packages
async getTokenPackages(): Promise<TokenPackage[]> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM token_packages ORDER BY created_at DESC'
);
return Array.isArray(rows) ? rows as TokenPackage[] : [];
} catch (error) {
$log.error('Error getting token packages:', error);
throw error;
} finally {
connection.release();
}
}
async getTokenPackageById(id: string): Promise<TokenPackage | null> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM token_packages WHERE id = ?',
[id]
);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0] as TokenPackage;
}
return null;
} catch (error) {
$log.error('Error getting token package by ID:', error);
throw error;
} finally {
connection.release();
}
}
async createTokenPackage(packageData: CreateTokenPackageRequest): Promise<TokenPackage> {
const connection = await pool.getConnection();
try {
const packageId = randomUUID();
await connection.execute(`
INSERT INTO token_packages (
id, name, description, quantity, price_per_token,
total_price, discount_percentage, is_popular, is_active,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
`, [
packageId,
packageData.name,
packageData.description,
packageData.quantity,
packageData.price_per_token,
packageData.total_price,
packageData.discount_percentage || 0,
packageData.is_popular || false,
packageData.is_active !== false
]);
return await this.getTokenPackageById(packageId) as TokenPackage;
} catch (error) {
$log.error('Error creating token package:', error);
throw error;
} finally {
connection.release();
}
}
async updateTokenPackage(id: string, packageData: UpdateTokenPackageRequest): Promise<TokenPackage | null> {
const connection = await pool.getConnection();
try {
const updateFields = [];
const values = [];
if (packageData.name) {
updateFields.push('name = ?');
values.push(packageData.name);
}
if (packageData.description) {
updateFields.push('description = ?');
values.push(packageData.description);
}
if (packageData.quantity) {
updateFields.push('quantity = ?');
values.push(packageData.quantity);
}
if (packageData.price_per_token) {
updateFields.push('price_per_token = ?');
values.push(packageData.price_per_token);
}
if (packageData.total_price) {
updateFields.push('total_price = ?');
values.push(packageData.total_price);
}
if (packageData.discount_percentage !== undefined) {
updateFields.push('discount_percentage = ?');
values.push(packageData.discount_percentage);
}
if (packageData.is_popular !== undefined) {
updateFields.push('is_popular = ?');
values.push(packageData.is_popular);
}
if (packageData.is_active !== undefined) {
updateFields.push('is_active = ?');
values.push(packageData.is_active);
}
if (updateFields.length === 0) {
throw new Error('No fields to update');
}
updateFields.push('updated_at = NOW()');
values.push(id);
await connection.execute(
`UPDATE token_packages SET ${updateFields.join(', ')} WHERE id = ?`,
values
);
return await this.getTokenPackageById(id);
} catch (error) {
$log.error('Error updating token package:', error);
throw error;
} finally {
connection.release();
}
}
async deleteTokenPackage(id: string): Promise<void> {
const connection = await pool.getConnection();
try {
await connection.execute(
'DELETE FROM token_packages WHERE id = ?',
[id]
);
} catch (error) {
$log.error('Error deleting token package:', error);
throw error;
} finally {
connection.release();
}
}
async toggleTokenPackageStatus(id: string): Promise<{ success: boolean; new_status: boolean }> {
const connection = await pool.getConnection();
try {
// Get current status
const [rows] = await connection.execute(
'SELECT is_active FROM token_packages WHERE id = ?',
[id]
);
if (Array.isArray(rows) && rows.length === 0) {
throw new Error('Token package not found');
}
const currentStatus = Array.isArray(rows) ? rows[0] : rows;
const newStatus = !currentStatus.is_active;
await connection.execute(
'UPDATE token_packages SET is_active = ?, updated_at = NOW() WHERE id = ?',
[newStatus, id]
);
return { success: true, new_status: newStatus };
} catch (error) {
$log.error('Error toggling token package status:', error);
throw error;
} finally {
connection.release();
}
}
// Interview Tokens
async getTokensByUserId(userId: string): Promise<InterviewToken[]> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM interview_tokens WHERE user_id = ? ORDER BY created_at DESC',
[userId]
);
return Array.isArray(rows) ? rows as InterviewToken[] : [];
} catch (error) {
$log.error('Error getting tokens by user ID:', error);
throw error;
} finally {
connection.release();
}
}
async getTokenById(id: string): Promise<InterviewToken | null> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM interview_tokens WHERE id = ?',
[id]
);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0] as InterviewToken;
}
return null;
} catch (error) {
$log.error('Error getting token by ID:', error);
throw error;
} finally {
connection.release();
}
}
async addTokensToUser(userId: string, quantity: number, pricePerToken: number): Promise<InterviewToken> {
const connection = await pool.getConnection();
try {
const totalPrice = quantity * pricePerToken;
const tokenId = randomUUID();
// Create token record
await connection.execute(`
INSERT INTO interview_tokens (
id, user_id, token_type, quantity, price_per_token,
total_price, status, purchased_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'active', NOW(), NOW(), NOW())
`, [
tokenId,
userId,
quantity === 1 ? 'single' : 'bulk',
quantity,
pricePerToken,
totalPrice
]);
// No payment record needed for admin-granted tokens
// Update user usage
await connection.execute(`
INSERT INTO user_usage (user_id, tokens_purchased)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE tokens_purchased = tokens_purchased + ?
`, [userId, quantity, quantity]);
return await this.getTokenById(tokenId) as InterviewToken;
} catch (error) {
$log.error('Error adding tokens to user:', error);
throw error;
} finally {
connection.release();
}
}
async useToken(tokenId: string): Promise<boolean> {
const connection = await pool.getConnection();
try {
// Get current token status
const token = await this.getTokenById(tokenId);
if (!token || token.status !== 'active') {
return false;
}
// Check if token has remaining uses
if (token.tokens_remaining <= 0) {
return false;
}
// Update token usage
const newUsedCount = token.tokens_used + 1;
const newStatus = newUsedCount >= token.quantity ? 'exhausted' : 'active';
await connection.execute(`
UPDATE interview_tokens
SET tokens_used = ?, status = ?, updated_at = NOW()
WHERE id = ?
`, [newUsedCount, newStatus, tokenId]);
// Update user usage
await connection.execute(`
INSERT INTO user_usage (user_id, tokens_used)
VALUES (?, 1)
ON DUPLICATE KEY UPDATE tokens_used = tokens_used + 1
`, [token.user_id]);
return true;
} catch (error) {
$log.error('Error using token:', error);
throw error;
} finally {
connection.release();
}
}
async getUserTokenSummary(userId: string): Promise<{
total_purchased: number;
total_used: number;
total_available: number;
utilization_percentage: number;
}> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
COALESCE(SUM(quantity), 0) as total_purchased,
COALESCE(SUM(tokens_used), 0) as total_used,
COALESCE(SUM(tokens_remaining), 0) as total_available
FROM interview_tokens
WHERE user_id = ? AND status = 'active'
`, [userId]);
const data = Array.isArray(rows) ? rows[0] : rows;
const totalPurchased = data?.total_purchased || 0;
const totalUsed = data?.total_used || 0;
const totalAvailable = data?.total_available || 0;
const utilizationPercentage = totalPurchased > 0
? Math.round((totalUsed / totalPurchased) * 100)
: 0;
return {
total_purchased: totalPurchased,
total_used: totalUsed,
total_available: totalAvailable,
utilization_percentage: utilizationPercentage
};
} catch (error) {
$log.error('Error getting user token summary:', error);
throw error;
} finally {
connection.release();
}
}
async getAllUserTokenSummaries(): Promise<Array<{
user_id: string;
first_name: string;
last_name: string;
email: string;
total_purchased: number;
total_used: number;
total_available: number;
utilization_percentage: number;
}>> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
u.id as user_id,
u.first_name,
u.last_name,
u.email,
COALESCE(SUM(it.quantity), 0) as total_purchased,
COALESCE(SUM(it.tokens_used), 0) as total_used,
COALESCE(SUM(it.tokens_remaining), 0) as total_available,
CASE
WHEN SUM(it.quantity) > 0 THEN ROUND((SUM(it.tokens_used) / SUM(it.quantity)) * 100, 2)
ELSE 0
END as utilization_percentage
FROM users u
LEFT JOIN interview_tokens it ON u.id = it.user_id AND it.status = 'active'
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.first_name, u.last_name, u.email
ORDER BY u.created_at DESC
`);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting all user token summaries:', error);
throw error;
} finally {
connection.release();
}
}
}

View File

@ -0,0 +1,223 @@
import { pool } from '../config/database.js';
import { User, CreateUserRequest, UpdateUserRequest, UserResponse } from '../models/User.js';
import { $log } from '@tsed/logger';
import bcrypt from 'bcryptjs';
import { randomUUID } from 'crypto';
export class UserService {
async createUser(userData: CreateUserRequest): Promise<UserResponse> {
const connection = await pool.getConnection();
try {
// Check if user already exists
const [existingUsers] = await connection.execute(
'SELECT id FROM users WHERE email = ? AND deleted_at IS NULL',
[userData.email]
);
if (Array.isArray(existingUsers) && existingUsers.length > 0) {
throw new Error('User with this email already exists');
}
// Hash password
const password_hash = await bcrypt.hash(userData.password, 10);
// Generate UUID for user ID
const userId = randomUUID();
// Insert user
const [result] = await connection.execute(
`INSERT INTO users (id, email, password_hash, first_name, last_name, role, company_name, is_active, email_verified_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW(), NOW())`,
[
userId,
userData.email,
password_hash,
userData.first_name,
userData.last_name,
userData.role || 'recruiter',
userData.company_name || null,
true
]
);
// Get the created user
const user = await this.getUserById(userId);
if (!user) {
throw new Error('Failed to create user');
}
return this.mapUserToResponse(user);
} catch (error) {
$log.error('Error creating user:', error);
throw error;
} finally {
connection.release();
}
}
async getUserByEmail(email: string): Promise<User | null> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM users WHERE email = ? AND deleted_at IS NULL',
[email]
);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0] as User;
}
return null;
} catch (error) {
$log.error('Error getting user by email:', error);
throw error;
} finally {
connection.release();
}
}
async getUserById(id: string): Promise<User | null> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM users WHERE id = ? AND deleted_at IS NULL',
[id]
);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0] as User;
}
return null;
} catch (error) {
$log.error('Error getting user by ID:', error);
throw error;
} finally {
connection.release();
}
}
async updateUser(id: string, userData: UpdateUserRequest): Promise<UserResponse | null> {
const connection = await pool.getConnection();
try {
const updateFields = [];
const values = [];
if (userData.first_name) {
updateFields.push('first_name = ?');
values.push(userData.first_name);
}
if (userData.last_name) {
updateFields.push('last_name = ?');
values.push(userData.last_name);
}
if (userData.company_name !== undefined) {
updateFields.push('company_name = ?');
values.push(userData.company_name);
}
if (userData.avatar_url !== undefined) {
updateFields.push('avatar_url = ?');
values.push(userData.avatar_url);
}
if (userData.is_active !== undefined) {
updateFields.push('is_active = ?');
values.push(userData.is_active);
}
if (updateFields.length === 0) {
throw new Error('No fields to update');
}
updateFields.push('updated_at = NOW()');
values.push(id);
await connection.execute(
`UPDATE users SET ${updateFields.join(', ')} WHERE id = ? AND deleted_at IS NULL`,
values
);
const user = await this.getUserById(id);
return user ? this.mapUserToResponse(user) : null;
} catch (error) {
$log.error('Error updating user:', error);
throw error;
} finally {
connection.release();
}
}
async updateLastLogin(id: string): Promise<void> {
const connection = await pool.getConnection();
try {
await connection.execute(
'UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
[id]
);
} catch (error) {
$log.error('Error updating last login:', error);
throw error;
} finally {
connection.release();
}
}
async verifyPassword(user: User, password: string): Promise<boolean> {
return await bcrypt.compare(password, user.password_hash);
}
async changePassword(id: string, newPassword: string): Promise<void> {
const connection = await pool.getConnection();
try {
const password_hash = await bcrypt.hash(newPassword, 10);
await connection.execute(
'UPDATE users SET password_hash = ?, updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
[password_hash, id]
);
} catch (error) {
$log.error('Error changing password:', error);
throw error;
} finally {
connection.release();
}
}
async softDeleteUser(id: string): Promise<void> {
const connection = await pool.getConnection();
try {
await connection.execute(
'UPDATE users SET deleted_at = NOW(), is_active = FALSE, updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
[id]
);
} catch (error) {
$log.error('Error soft deleting user:', error);
throw error;
} finally {
connection.release();
}
}
private mapUserToResponse(user: User): UserResponse {
return {
id: user.id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
company_name: user.company_name,
avatar_url: user.avatar_url,
is_active: user.is_active,
last_login_at: user.last_login_at,
email_verified_at: user.email_verified_at,
created_at: user.created_at,
updated_at: user.updated_at
};
}
}

171
backend/src/types/admin.ts Normal file
View File

@ -0,0 +1,171 @@
// Admin-specific types
export interface SystemStatistics {
total_users: number;
active_users: number;
total_jobs: number;
total_interviews: number;
total_tokens_purchased: number;
total_tokens_used: number;
total_revenue: number;
generated_at: string;
}
export interface UserWithStats {
id: string;
email: string;
first_name: string;
last_name: string;
role: 'admin' | 'recruiter';
company_name?: string;
avatar_url?: string;
is_active: boolean;
last_login_at?: string;
email_verified_at?: string;
created_at: string;
updated_at: string;
}
export interface JobWithUser {
id: string;
user_id: string;
title: string;
description: string;
requirements: string;
skills_required: string[];
location: string;
employment_type: string;
experience_level: string;
salary_min?: number;
salary_max?: number;
currency: string;
status: string;
evaluation_criteria: any;
interview_questions: any;
application_deadline?: string;
created_at: string;
updated_at: string;
first_name?: string;
last_name?: string;
email?: string;
company_name?: string;
}
export interface UserTokenSummary {
user_id: string;
first_name: string;
last_name: string;
email: string;
total_purchased: number;
total_used: number;
total_available: number;
utilization_percentage: number;
}
export interface TokenPackage {
id: string;
name: string;
description: string;
quantity: number;
price_per_token: number;
total_price: number;
discount_percentage: number;
is_popular: boolean;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface AddTokensRequest {
user_id: string;
quantity: number;
price_per_token: number;
total_price: number;
}
export interface CreateUserRequest {
email: string;
password: string;
first_name: string;
last_name: string;
role: 'admin' | 'recruiter';
company_name?: string;
}
export interface UpdateUserRequest {
first_name?: string;
last_name?: string;
email?: string;
role?: 'admin' | 'recruiter';
company_name?: string;
avatar_url?: string;
is_active?: boolean;
}
export interface CreateTokenPackageRequest {
name: string;
description: string;
quantity: number;
price_per_token: number;
total_price: number;
discount_percentage?: number;
is_popular?: boolean;
is_active?: boolean;
}
export interface UpdateTokenPackageRequest {
name?: string;
description?: string;
quantity?: number;
price_per_token?: number;
total_price?: number;
discount_percentage?: number;
is_popular?: boolean;
is_active?: boolean;
}
export interface InterviewWithDetails {
id: string;
user_id: string;
candidate_id: string;
job_id: string;
token: string;
status: string;
started_at?: string;
completed_at?: string;
duration_minutes: number;
ai_questions: any;
candidate_responses: any;
ai_evaluation: any;
overall_score?: number;
technical_score?: number;
communication_score?: number;
culture_fit_score?: number;
ai_feedback?: string;
created_at: string;
updated_at: string;
first_name?: string;
last_name?: string;
email?: string;
job_title?: string;
}
export interface PaymentRecord {
id: string;
user_id: string;
interview_token_id?: string;
token_package_id?: string;
amount: number;
currency: string;
status: string;
payment_method?: string;
payment_reference?: string;
invoice_url?: string;
paid_at?: string;
created_at: string;
updated_at: string;
first_name?: string;
last_name?: string;
email?: string;
package_name?: string;
}

51
backend/src/types/auth.ts Normal file
View File

@ -0,0 +1,51 @@
// Authentication types that will be preserved in JavaScript compilation
export const LoginRequestSchema = {
email: String,
password: String
};
export const RegisterRequestSchema = {
email: String,
password: String,
first_name: String,
last_name: String,
company_name: String
};
export const CreateUserRequestSchema = {
email: String,
password: String,
first_name: String,
last_name: String,
company_name: String,
role: String
};
export const UpdateUserRequestSchema = {
first_name: String,
last_name: String,
company_name: String,
avatar_url: String,
is_active: Boolean
};
export const UserResponseSchema = {
id: String,
email: String,
first_name: String,
last_name: String,
role: String,
company_name: String,
avatar_url: String,
is_active: Boolean,
last_login_at: Date,
email_verified_at: Date,
created_at: Date,
updated_at: Date
};
export const LoginResponseSchema = {
token: String,
user: UserResponseSchema
};

View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"module": "NodeNext",
"target": "ESNext",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"moduleResolution": "NodeNext",
"downlevelIteration": false,
"isolatedModules": false,
"suppressImplicitAnyIndexErrors": false,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"useDefineForClassFields": false,
"importHelpers": true,
"resolveJsonModule": true,
"newLine": "LF",
"skipLibCheck": true,
"lib": ["ESNext", "esnext.asynciterable"],
"declaration": false,
"noResolve": false,
"preserveConstEnums": true,
"sourceMap": true,
"noEmit": true
}
}

13
backend/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"noEmit": true
},
"include": [],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@ -0,0 +1,20 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"baseUrl": "."
},
"include": [
"src/**/*"
],
"exclude": [
"src/**/*.spec.ts",
"dist",
"node_modules",
"**/helpers/*Fixture.ts",
"**/__mock__/**",
"coverage"
],
"linterOptions": {
"exclude": []
}
}

View File

@ -0,0 +1 @@

95
backend/views/index.ejs Normal file
View File

@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Swagger UI</title>
<link href="https://fonts.googleapis.com/css?family=Open+Sans:400,700|Source+Code+Pro:300,600|Titillium+Web:400,600,700"
rel="stylesheet">
<link rel="stylesheet" type="text/css" href="./swagger-ui.css">
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32"/>
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16"/>
<style>
<% if (!showExplorer) { %>
.swagger-ui .topbar .download-url-wrapper {
display: none
}
<% } %>
</style>
<% if (cssPath) { %>
<link rel="stylesheet" type="text/css" href="<%= cssPath %>">
<% } %>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js"></script>
<script src="./swagger-ui-standalone-preset.js"></script>
<script>
const initialOptions = <%- JSON.stringify(swaggerOptions) %>;
const currentUrl = window.origin + "<%- url %>";
const urls = <%- JSON.stringify(urls) %>
.map(function (o) {
if (!o.url.match(/^https?:/)) {
const url = window.origin + o.url;
return {
name: o.name,
url: url,
selected: url === currentUrl
}
}
return o;
})
.sort(function (a, b) {
return a.selected ? -1 : 1
});
const SwaggerUIBuilder = {
config: Object.assign({
urls: urls,
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout",
oauth2RedirectUrl: currentUrl.replace('swagger.json', 'oauth2-redirect.html')
}, initialOptions),
/**
*
*/
init: function () {
this.ui = SwaggerUIBundle(this.config);
if (this.config.oauth) {
this.ui.initOAuth(this.config.oauth);
}
if (this.config.authorize) {
this.ui.authActions.authorize(this.config.authorize);
}
window.ui = this.ui;
const event = new Event('swagger.init');
document.dispatchEvent(event);
}
}
</script>
<% if (jsPath) { %>
<script src="<%= jsPath %>"></script>
<% } %>
<script>
window.onload = function () {
SwaggerUIBuilder.init();
}
</script>
</body>
</html>

100
backend/views/swagger.ejs Normal file
View File

@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>client</title>
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,600,700" rel="stylesheet" />
<style>
body, h1 {
font-family: Source Sans Pro,sans-serif;
}
body:after {
content: "";
background-image: radial-gradient(#eef2f5 0,#f4f7f8 40%,transparent 75%);
position: absolute;
top: 0;
right: 0;
width: 60%;
height: 100%;
z-index: 1;
}
.container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.container-logo {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 60px;
}
.container-logo img {
max-width: 150px;
border-radius: 50%;
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 40px;
}
ul li a {
padding-left: 1rem;
padding-right: 1rem;
padding-top: .25rem;
padding-bottom: .25rem;
margin-left: 10px;
margin-right: 10px;
border: 2px solid #504747;
min-width: 110px;
border-radius: 10px;
text-align: center;
display: block;
border-radius: 1rem;
color: #504747;
text-decoration: none;
transition: all ease-in-out 0.5s;
}
ul li a:hover {
color: #14a5c2;
border-color: #14a5c2;
}
ul li a span {
margin: .25rem;
display: block;
}
</style>
</head>
<body>
<div class="container">
<div>
<div class="container-logo">
<img src="https://tsed.dev/tsed-og.png" alt="Ts.ED">
</div>
<ul>
<% docs.forEach((doc) => { %>
<li><a href="<%= doc.path %>"><span>OpenSpec <%= doc.specVersion %></span></a></li>
<% }) %>
</ul>
</div>
</div>
<!-- built files will be auto injected -->
</body>
</html>

15
database/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
# Use MySQL 8.0 as base image
FROM mysql:8.0
# Rely on runtime environment variables provided by docker-compose
# (MYSQL_ROOT_PASSWORD, MYSQL_DATABASE, MYSQL_USER, MYSQL_PASSWORD)
# Copy only the deploy_dump.sql file with deterministic name for init order
COPY deploy_dump.sql /docker-entrypoint-initdb.d/00_deploy_dump.sql
# Expose MySQL port
EXPOSE 3306
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD mysqladmin ping -h localhost || exit 1

View File

@ -0,0 +1,55 @@
CREATE DATABASE IF NOT EXISTS `candidb_main` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `candidb_main`;
-- MySQL dump 10.13 Distrib 8.0.38, for Win64 (x86_64)
--
-- Host: localhost Database: candidb_main
-- ------------------------------------------------------
-- Server version 8.0.39
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `audit_logs`
--
DROP TABLE IF EXISTS `audit_logs`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `audit_logs` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`user_id` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`action` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`resource_type` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`resource_id` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`old_values` json DEFAULT NULL,
`new_values` json DEFAULT NULL,
`ip_address` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`user_agent` text COLLATE utf8mb4_unicode_ci,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_action` (`user_id`,`action`),
KEY `idx_resource` (`resource_type`,`resource_id`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `audit_logs_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-09-16 20:22:32

View File

@ -0,0 +1,53 @@
CREATE DATABASE IF NOT EXISTS `candidb_main` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `candidb_main`;
-- MySQL dump 10.13 Distrib 8.0.38, for Win64 (x86_64)
--
-- Host: localhost Database: candidb_main
-- ------------------------------------------------------
-- Server version 8.0.39
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `candidate_responses`
--
DROP TABLE IF EXISTS `candidate_responses`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `candidate_responses` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`interview_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`question_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`response_text` text COLLATE utf8mb4_unicode_ci NOT NULL,
`response_audio_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`ai_score` decimal(5,2) DEFAULT NULL,
`ai_feedback` text COLLATE utf8mb4_unicode_ci,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_response_per_question` (`interview_id`,`question_id`),
KEY `question_id` (`question_id`),
CONSTRAINT `candidate_responses_ibfk_1` FOREIGN KEY (`interview_id`) REFERENCES `interviews` (`id`) ON DELETE CASCADE,
CONSTRAINT `candidate_responses_ibfk_2` FOREIGN KEY (`question_id`) REFERENCES `interview_questions` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-09-16 20:22:32

View File

@ -0,0 +1,63 @@
CREATE DATABASE IF NOT EXISTS `candidb_main` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `candidb_main`;
-- MySQL dump 10.13 Distrib 8.0.38, for Win64 (x86_64)
--
-- Host: localhost Database: candidb_main
-- ------------------------------------------------------
-- Server version 8.0.39
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `candidates`
--
DROP TABLE IF EXISTS `candidates`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `candidates` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`user_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`job_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`first_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`last_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`phone` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`resume_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`cover_letter` text COLLATE utf8mb4_unicode_ci,
`source` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`status` enum('applied','interviewing','evaluated','hired','rejected') COLLATE utf8mb4_unicode_ci DEFAULT 'applied',
`applied_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`last_activity_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_candidate_per_job` (`job_id`,`email`),
KEY `idx_user_job` (`user_id`,`job_id`),
KEY `idx_status` (`status`),
KEY `idx_candidates_user_job_status` (`user_id`,`job_id`,`status`),
CONSTRAINT `candidates_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `candidates_ibfk_2` FOREIGN KEY (`job_id`) REFERENCES `jobs` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-09-16 20:22:32

View File

@ -0,0 +1,52 @@
CREATE DATABASE IF NOT EXISTS `candidb_main` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `candidb_main`;
-- MySQL dump 10.13 Distrib 8.0.38, for Win64 (x86_64)
--
-- Host: localhost Database: candidb_main
-- ------------------------------------------------------
-- Server version 8.0.39
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `conversation_messages`
--
DROP TABLE IF EXISTS `conversation_messages`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `conversation_messages` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`interview_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`link_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`sender` enum('candidate','ai') COLLATE utf8mb4_unicode_ci NOT NULL,
`message` text COLLATE utf8mb4_unicode_ci NOT NULL,
`message_data` json DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_interview_id` (`interview_id`),
KEY `idx_link_id` (`link_id`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `conversation_messages_ibfk_1` FOREIGN KEY (`interview_id`) REFERENCES `interviews` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-09-16 20:22:31

View File

@ -0,0 +1,52 @@
CREATE DATABASE IF NOT EXISTS `candidb_main` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `candidb_main`;
-- MySQL dump 10.13 Distrib 8.0.38, for Win64 (x86_64)
--
-- Host: localhost Database: candidb_main
-- ------------------------------------------------------
-- Server version 8.0.39
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `interview_events`
--
DROP TABLE IF EXISTS `interview_events`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `interview_events` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`job_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`link_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`event_type` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`event_data` json DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_job_id` (`job_id`),
KEY `idx_link_id` (`link_id`),
KEY `idx_event_type` (`event_type`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `interview_events_ibfk_1` FOREIGN KEY (`job_id`) REFERENCES `jobs` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-09-16 20:22:32

View File

@ -0,0 +1,52 @@
CREATE DATABASE IF NOT EXISTS `candidb_main` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `candidb_main`;
-- MySQL dump 10.13 Distrib 8.0.38, for Win64 (x86_64)
--
-- Host: localhost Database: candidb_main
-- ------------------------------------------------------
-- Server version 8.0.39
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `interview_questions`
--
DROP TABLE IF EXISTS `interview_questions`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `interview_questions` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`interview_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`question_text` text COLLATE utf8mb4_unicode_ci NOT NULL,
`question_type` enum('technical','behavioral','situational','culture_fit') COLLATE utf8mb4_unicode_ci NOT NULL,
`difficulty_level` enum('easy','medium','hard') COLLATE utf8mb4_unicode_ci DEFAULT 'medium',
`expected_answer` text COLLATE utf8mb4_unicode_ci,
`evaluation_criteria` json DEFAULT NULL,
`order_index` int NOT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_interview_order` (`interview_id`,`order_index`),
CONSTRAINT `interview_questions_ibfk_1` FOREIGN KEY (`interview_id`) REFERENCES `interviews` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-09-16 20:22:32

View File

@ -0,0 +1,79 @@
CREATE DATABASE IF NOT EXISTS `candidb_main` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `candidb_main`;
-- MySQL dump 10.13 Distrib 8.0.38, for Win64 (x86_64)
--
-- Host: localhost Database: candidb_main
-- ------------------------------------------------------
-- Server version 8.0.39
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `interview_tokens`
--
DROP TABLE IF EXISTS `interview_tokens`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `interview_tokens` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`user_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`token_type` enum('single','bulk') COLLATE utf8mb4_unicode_ci NOT NULL,
`quantity` int NOT NULL DEFAULT '1',
`price_per_token` decimal(10,2) NOT NULL,
`total_price` decimal(10,2) NOT NULL,
`tokens_used` int DEFAULT '0',
`tokens_remaining` int GENERATED ALWAYS AS ((`quantity` - `tokens_used`)) STORED,
`status` enum('active','exhausted','expired') COLLATE utf8mb4_unicode_ci DEFAULT 'active',
`expires_at` timestamp NULL DEFAULT NULL,
`purchased_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_status` (`user_id`,`status`),
KEY `idx_expires_at` (`expires_at`),
KEY `idx_interview_tokens_user_active` (`user_id`,`status`),
CONSTRAINT `interview_tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `chk_interview_tokens_quantity_positive` CHECK ((`quantity` > 0)),
CONSTRAINT `chk_interview_tokens_used_valid` CHECK (((`tokens_used` >= 0) and (`tokens_used` <= `quantity`)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
/*!50003 SET character_set_client = utf8mb4 */ ;
/*!50003 SET character_set_results = utf8mb4 */ ;
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' */ ;
DELIMITER ;;
/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`localhost`*/ /*!50003 TRIGGER `update_token_usage_after_purchase` AFTER INSERT ON `interview_tokens` FOR EACH ROW BEGIN
INSERT INTO user_usage (user_id, tokens_purchased)
VALUES (NEW.user_id, NEW.quantity)
ON DUPLICATE KEY UPDATE tokens_purchased = tokens_purchased + NEW.quantity;
END */;;
DELIMITER ;
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-09-16 20:22:32

View File

@ -0,0 +1,96 @@
CREATE DATABASE IF NOT EXISTS `candidb_main` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `candidb_main`;
-- MySQL dump 10.13 Distrib 8.0.38, for Win64 (x86_64)
--
-- Host: localhost Database: candidb_main
-- ------------------------------------------------------
-- Server version 8.0.39
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `interviews`
--
DROP TABLE IF EXISTS `interviews`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `interviews` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`user_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`candidate_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`job_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`status` enum('scheduled','in_progress','completed','abandoned') COLLATE utf8mb4_unicode_ci DEFAULT 'scheduled',
`started_at` timestamp NULL DEFAULT NULL,
`completed_at` timestamp NULL DEFAULT NULL,
`duration_minutes` int DEFAULT '0',
`ai_questions` json DEFAULT NULL,
`candidate_responses` json DEFAULT NULL,
`ai_evaluation` json DEFAULT NULL,
`overall_score` decimal(5,2) DEFAULT NULL,
`technical_score` decimal(5,2) DEFAULT NULL,
`communication_score` decimal(5,2) DEFAULT NULL,
`culture_fit_score` decimal(5,2) DEFAULT NULL,
`ai_feedback` text COLLATE utf8mb4_unicode_ci,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `token` (`token`),
KEY `candidate_id` (`candidate_id`),
KEY `job_id` (`job_id`),
KEY `idx_token` (`token`),
KEY `idx_user_candidate` (`user_id`,`candidate_id`),
KEY `idx_status` (`status`),
KEY `idx_interviews_user_status` (`user_id`,`status`),
KEY `idx_interviews_token_status` (`token`,`status`),
CONSTRAINT `interviews_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `interviews_ibfk_2` FOREIGN KEY (`candidate_id`) REFERENCES `candidates` (`id`) ON DELETE CASCADE,
CONSTRAINT `interviews_ibfk_3` FOREIGN KEY (`job_id`) REFERENCES `jobs` (`id`) ON DELETE CASCADE,
CONSTRAINT `chk_scores_valid` CHECK ((((`overall_score` is null) or ((`overall_score` >= 0) and (`overall_score` <= 100))) and ((`technical_score` is null) or ((`technical_score` >= 0) and (`technical_score` <= 100))) and ((`communication_score` is null) or ((`communication_score` >= 0) and (`communication_score` <= 100))) and ((`culture_fit_score` is null) or ((`culture_fit_score` >= 0) and (`culture_fit_score` <= 100)))))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
/*!50003 SET character_set_client = utf8mb4 */ ;
/*!50003 SET character_set_results = utf8mb4 */ ;
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' */ ;
DELIMITER ;;
/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`localhost`*/ /*!50003 TRIGGER `update_interview_usage_after_complete` AFTER UPDATE ON `interviews` FOR EACH ROW BEGIN
IF OLD.status != 'completed' AND NEW.status = 'completed' THEN
-- Update user usage
INSERT INTO user_usage (user_id, interviews_completed, tokens_used)
VALUES (NEW.user_id, 1, 1)
ON DUPLICATE KEY UPDATE
interviews_completed = interviews_completed + 1,
tokens_used = tokens_used + 1;
END IF;
END */;;
DELIMITER ;
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-09-16 20:22:31

View File

@ -0,0 +1,52 @@
CREATE DATABASE IF NOT EXISTS `candidb_main` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `candidb_main`;
-- MySQL dump 10.13 Distrib 8.0.38, for Win64 (x86_64)
--
-- Host: localhost Database: candidb_main
-- ------------------------------------------------------
-- Server version 8.0.39
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `job_links`
--
DROP TABLE IF EXISTS `job_links`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `job_links` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`job_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`url_slug` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`tokens_available` int DEFAULT '0',
`tokens_used` int DEFAULT '0',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `url_slug` (`url_slug`),
KEY `idx_job_id` (`job_id`),
KEY `idx_url_slug` (`url_slug`),
CONSTRAINT `job_links_ibfk_1` FOREIGN KEY (`job_id`) REFERENCES `jobs` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-09-16 20:22:32

View File

@ -0,0 +1,85 @@
CREATE DATABASE IF NOT EXISTS `candidb_main` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `candidb_main`;
-- MySQL dump 10.13 Distrib 8.0.38, for Win64 (x86_64)
--
-- Host: localhost Database: candidb_main
-- ------------------------------------------------------
-- Server version 8.0.39
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `jobs`
--
DROP TABLE IF EXISTS `jobs`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `jobs` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`user_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`title` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`description` text COLLATE utf8mb4_unicode_ci NOT NULL,
`requirements` text COLLATE utf8mb4_unicode_ci NOT NULL,
`skills_required` json DEFAULT NULL,
`location` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`employment_type` enum('full_time','part_time','contract','internship') COLLATE utf8mb4_unicode_ci DEFAULT 'full_time',
`experience_level` enum('entry','mid','senior','lead','executive') COLLATE utf8mb4_unicode_ci DEFAULT 'mid',
`salary_min` decimal(10,2) DEFAULT NULL,
`salary_max` decimal(10,2) DEFAULT NULL,
`currency` varchar(3) COLLATE utf8mb4_unicode_ci DEFAULT 'USD',
`status` enum('draft','active','paused','closed') COLLATE utf8mb4_unicode_ci DEFAULT 'draft',
`evaluation_criteria` json DEFAULT NULL,
`interview_questions` json DEFAULT NULL,
`interview_style` enum('personal','balanced','technical') COLLATE utf8mb4_unicode_ci DEFAULT 'balanced',
`application_deadline` timestamp NULL DEFAULT NULL,
`icon` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT 'briefcase',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_status` (`user_id`,`status`),
KEY `idx_created_at` (`created_at`),
KEY `idx_jobs_user_status_created` (`user_id`,`status`,`created_at` DESC),
CONSTRAINT `jobs_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
/*!50003 SET character_set_client = utf8mb4 */ ;
/*!50003 SET character_set_results = utf8mb4 */ ;
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' */ ;
DELIMITER ;;
/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`localhost`*/ /*!50003 TRIGGER `update_job_usage_after_insert` AFTER INSERT ON `jobs` FOR EACH ROW BEGIN
INSERT INTO user_usage (user_id, jobs_created)
VALUES (NEW.user_id, 1)
ON DUPLICATE KEY UPDATE jobs_created = jobs_created + 1;
END */;;
DELIMITER ;
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-09-16 20:22:31

View File

@ -0,0 +1,59 @@
CREATE DATABASE IF NOT EXISTS `candidb_main` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `candidb_main`;
-- MySQL dump 10.13 Distrib 8.0.38, for Win64 (x86_64)
--
-- Host: localhost Database: candidb_main
-- ------------------------------------------------------
-- Server version 8.0.39
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `payment_records`
--
DROP TABLE IF EXISTS `payment_records`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `payment_records` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`user_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`token_package_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`amount` decimal(10,2) NOT NULL,
`currency` varchar(3) COLLATE utf8mb4_unicode_ci DEFAULT 'USD',
`status` enum('pending','paid','failed','refunded','cancelled') COLLATE utf8mb4_unicode_ci DEFAULT 'pending',
`payment_method` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`payment_reference` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`invoice_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`paid_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `token_package_id` (`token_package_id`),
KEY `idx_user_status` (`user_id`,`status`),
KEY `idx_payment_reference` (`payment_reference`),
KEY `idx_payment_records_user_created` (`user_id`,`created_at` DESC),
CONSTRAINT `payment_records_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `payment_records_ibfk_2` FOREIGN KEY (`token_package_id`) REFERENCES `token_packages` (`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-09-16 20:22:31

View File

@ -0,0 +1,631 @@
CREATE DATABASE IF NOT EXISTS `candidb_main` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `candidb_main`;
-- MySQL dump 10.13 Distrib 8.0.38, for Win64 (x86_64)
--
-- Host: localhost Database: candidb_main
-- ------------------------------------------------------
-- Server version 8.0.39
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Dumping events for database 'candidb_main'
--
--
-- Dumping routines for database 'candidb_main'
--
/*!50003 DROP FUNCTION IF EXISTS `can_create_job` */;
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
/*!50003 SET character_set_client = utf8mb4 */ ;
/*!50003 SET character_set_results = utf8mb4 */ ;
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' */ ;
DELIMITER ;;
CREATE DEFINER=`root`@`localhost` FUNCTION `can_create_job`(user_uuid VARCHAR(36)) RETURNS tinyint(1)
READS SQL DATA
DETERMINISTIC
BEGIN
DECLARE current_jobs INT DEFAULT 0;
DECLARE max_jobs INT DEFAULT 100; -- Hard limit of 100 jobs
-- Get current job count
SELECT COALESCE(jobs_created, 0) INTO current_jobs
FROM user_usage
WHERE user_id = user_uuid;
-- Return TRUE if under limit
RETURN current_jobs < max_jobs;
END ;;
DELIMITER ;
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;
/*!50003 DROP FUNCTION IF EXISTS `get_all_users` */;
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
/*!50003 SET character_set_client = utf8mb4 */ ;
/*!50003 SET character_set_results = utf8mb4 */ ;
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' */ ;
DELIMITER ;;
CREATE DEFINER=`root`@`localhost` FUNCTION `get_all_users`() RETURNS json
READS SQL DATA
DETERMINISTIC
BEGIN
DECLARE result JSON;
SELECT JSON_ARRAYAGG(
JSON_OBJECT(
'id', id,
'email', email,
'first_name', first_name,
'last_name', last_name,
'role', role,
'company_name', company_name,
'is_active', is_active,
'last_login_at', last_login_at,
'email_verified_at', email_verified_at,
'created_at', created_at
)
) INTO result
FROM users
WHERE deleted_at IS NULL
ORDER BY created_at DESC;
RETURN result;
END ;;
DELIMITER ;
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;
/*!50003 DROP FUNCTION IF EXISTS `get_token_usage_summary` */;
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
/*!50003 SET character_set_client = utf8mb4 */ ;
/*!50003 SET character_set_results = utf8mb4 */ ;
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' */ ;
DELIMITER ;;
CREATE DEFINER=`root`@`localhost` FUNCTION `get_token_usage_summary`(user_uuid VARCHAR(36)) RETURNS json
READS SQL DATA
DETERMINISTIC
BEGIN
DECLARE total_purchased INT DEFAULT 0;
DECLARE total_used INT DEFAULT 0;
DECLARE total_available INT DEFAULT 0;
DECLARE result JSON;
-- Get total purchased tokens
SELECT COALESCE(SUM(quantity), 0) INTO total_purchased
FROM interview_tokens
WHERE user_id = user_uuid;
-- Get total used tokens
SELECT COALESCE(SUM(tokens_used), 0) INTO total_used
FROM interview_tokens
WHERE user_id = user_uuid;
-- Get total available tokens
SELECT COALESCE(SUM(tokens_remaining), 0) INTO total_available
FROM interview_tokens
WHERE user_id = user_uuid
AND status = 'active'
AND (expires_at IS NULL OR expires_at > NOW());
-- Build JSON result
SET result = JSON_OBJECT(
'total_purchased', total_purchased,
'total_used', total_used,
'total_available', total_available,
'utilization_percentage', CASE
WHEN total_purchased > 0 THEN ROUND((total_used / total_purchased) * 100, 2)
ELSE 0
END
);
RETURN result;
END ;;
DELIMITER ;
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;
/*!50003 DROP FUNCTION IF EXISTS `get_user_statistics` */;
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
/*!50003 SET character_set_client = utf8mb4 */ ;
/*!50003 SET character_set_results = utf8mb4 */ ;
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' */ ;
DELIMITER ;;
CREATE DEFINER=`root`@`localhost` FUNCTION `get_user_statistics`(user_uuid VARCHAR(36)) RETURNS json
READS SQL DATA
DETERMINISTIC
BEGIN
DECLARE result JSON;
DECLARE user_usage_data JSON;
DECLARE token_summary JSON;
-- Get usage data
SELECT JSON_OBJECT(
'jobs_created', COALESCE(jobs_created, 0),
'interviews_completed', COALESCE(interviews_completed, 0),
'tokens_purchased', COALESCE(tokens_purchased, 0),
'tokens_used', COALESCE(tokens_used, 0)
) INTO user_usage_data
FROM user_usage
WHERE user_id = user_uuid;
-- Get token summary
SELECT get_token_usage_summary(user_uuid) INTO token_summary;
-- Build result
SET result = JSON_OBJECT(
'usage', user_usage_data,
'tokens', token_summary
);
RETURN result;
END ;;
DELIMITER ;
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;
/*!50003 DROP FUNCTION IF EXISTS `has_available_tokens` */;
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
/*!50003 SET character_set_client = utf8mb4 */ ;
/*!50003 SET character_set_results = utf8mb4 */ ;
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' */ ;
DELIMITER ;;
CREATE DEFINER=`root`@`localhost` FUNCTION `has_available_tokens`(user_uuid VARCHAR(36)) RETURNS tinyint(1)
READS SQL DATA
DETERMINISTIC
BEGIN
DECLARE available_tokens INT DEFAULT 0;
-- Get available tokens (active and not expired)
SELECT COALESCE(SUM(tokens_remaining), 0) INTO available_tokens
FROM interview_tokens
WHERE user_id = user_uuid
AND status = 'active'
AND (expires_at IS NULL OR expires_at > NOW());
-- Return TRUE if has available tokens
RETURN available_tokens > 0;
END ;;
DELIMITER ;
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;
/*!50003 DROP FUNCTION IF EXISTS `is_admin` */;
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
/*!50003 SET character_set_client = utf8mb4 */ ;
/*!50003 SET character_set_results = utf8mb4 */ ;
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' */ ;
DELIMITER ;;
CREATE DEFINER=`root`@`localhost` FUNCTION `is_admin`(user_uuid VARCHAR(36)) RETURNS tinyint(1)
READS SQL DATA
DETERMINISTIC
BEGIN
DECLARE user_role VARCHAR(20) DEFAULT NULL;
SELECT role INTO user_role
FROM users
WHERE id = user_uuid AND is_active = TRUE;
RETURN user_role = 'admin';
END ;;
DELIMITER ;
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;
/*!50003 DROP PROCEDURE IF EXISTS `add_tokens_to_user` */;
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
/*!50003 SET character_set_client = utf8mb4 */ ;
/*!50003 SET character_set_results = utf8mb4 */ ;
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' */ ;
DELIMITER ;;
CREATE DEFINER=`root`@`localhost` PROCEDURE `add_tokens_to_user`(
IN p_user_id VARCHAR(36),
IN p_quantity INT,
IN p_price_per_token DECIMAL(10,2),
IN p_admin_id VARCHAR(36),
OUT p_success BOOLEAN,
OUT p_message VARCHAR(255)
)
BEGIN
DECLARE v_total_price DECIMAL(10,2);
DECLARE v_token_id VARCHAR(36);
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_success = FALSE;
SET p_message = 'An error occurred while adding tokens';
END;
-- Check if admin
IF NOT is_admin(p_admin_id) THEN
SET p_success = FALSE;
SET p_message = 'Access denied: Admin privileges required';
ELSE
-- Check if user exists
IF NOT EXISTS (SELECT 1 FROM users WHERE id = p_user_id AND deleted_at IS NULL) THEN
SET p_success = FALSE;
SET p_message = 'User not found';
ELSE
-- Calculate total price
SET v_total_price = p_quantity * p_price_per_token;
-- Create token record
SET v_token_id = UUID();
INSERT INTO interview_tokens (
id, user_id, token_type, quantity, price_per_token,
total_price, status, purchased_at
) VALUES (
v_token_id, p_user_id,
CASE WHEN p_quantity = 1 THEN 'single' ELSE 'bulk' END,
p_quantity, p_price_per_token, v_total_price,
'active', NOW()
);
-- Create payment record (admin-granted)
INSERT INTO payment_records (
user_id, interview_token_id, token_package_id,
amount, status, payment_method, payment_reference
) VALUES (
p_user_id, v_token_id, NULL, v_total_price,
'paid', 'admin_granted', CONCAT('ADMIN_', p_admin_id, '_', NOW())
);
SET p_success = TRUE;
SET p_message = CONCAT('Successfully added ', p_quantity, ' tokens to user');
END IF;
END IF;
END ;;
DELIMITER ;
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;
/*!50003 DROP PROCEDURE IF EXISTS `change_user_password` */;
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
/*!50003 SET character_set_client = utf8mb4 */ ;
/*!50003 SET character_set_results = utf8mb4 */ ;
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' */ ;
DELIMITER ;;
CREATE DEFINER=`root`@`localhost` PROCEDURE `change_user_password`(
IN p_user_id VARCHAR(36),
IN p_new_password_hash VARCHAR(255),
IN p_admin_id VARCHAR(36),
OUT p_success BOOLEAN,
OUT p_message VARCHAR(255)
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_success = FALSE;
SET p_message = 'An error occurred while changing password';
END;
-- Check if admin
IF NOT is_admin(p_admin_id) THEN
SET p_success = FALSE;
SET p_message = 'Access denied: Admin privileges required';
ELSE
-- Check if user exists
IF NOT EXISTS (SELECT 1 FROM users WHERE id = p_user_id AND deleted_at IS NULL) THEN
SET p_success = FALSE;
SET p_message = 'User not found';
ELSE
-- Update password
UPDATE users SET
password_hash = p_new_password_hash,
updated_at = NOW()
WHERE id = p_user_id;
SET p_success = TRUE;
SET p_message = 'Password changed successfully';
END IF;
END IF;
END ;;
DELIMITER ;
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;
/*!50003 DROP PROCEDURE IF EXISTS `create_user` */;
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
/*!50003 SET character_set_client = utf8mb4 */ ;
/*!50003 SET character_set_results = utf8mb4 */ ;
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' */ ;
DELIMITER ;;
CREATE DEFINER=`root`@`localhost` PROCEDURE `create_user`(
IN p_email VARCHAR(255),
IN p_password_hash VARCHAR(255),
IN p_first_name VARCHAR(100),
IN p_last_name VARCHAR(100),
IN p_role ENUM('admin', 'recruiter'),
IN p_company_name VARCHAR(255),
IN p_admin_id VARCHAR(36),
OUT p_user_id VARCHAR(36),
OUT p_success BOOLEAN,
OUT p_message VARCHAR(255)
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_success = FALSE;
SET p_message = 'An error occurred while creating user';
END;
-- Check if admin
IF NOT is_admin(p_admin_id) THEN
SET p_success = FALSE;
SET p_message = 'Access denied: Admin privileges required';
ELSE
-- Check if email already exists
IF EXISTS (SELECT 1 FROM users WHERE email = p_email AND deleted_at IS NULL) THEN
SET p_success = FALSE;
SET p_message = 'Email already exists';
ELSE
-- Create user
SET p_user_id = UUID();
INSERT INTO users (
id, email, password_hash, first_name, last_name,
role, company_name, is_active, email_verified_at
) VALUES (
p_user_id, p_email, p_password_hash, p_first_name, p_last_name,
p_role, p_company_name, TRUE, NOW()
);
-- Initialize usage tracking
INSERT INTO user_usage (user_id) VALUES (p_user_id);
SET p_success = TRUE;
SET p_message = 'User created successfully';
END IF;
END IF;
END ;;
DELIMITER ;
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;
/*!50003 DROP PROCEDURE IF EXISTS `deactivate_user` */;
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
/*!50003 SET character_set_client = utf8mb4 */ ;
/*!50003 SET character_set_results = utf8mb4 */ ;
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' */ ;
DELIMITER ;;
CREATE DEFINER=`root`@`localhost` PROCEDURE `deactivate_user`(
IN p_user_id VARCHAR(36),
IN p_admin_id VARCHAR(36),
OUT p_success BOOLEAN,
OUT p_message VARCHAR(255)
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_success = FALSE;
SET p_message = 'An error occurred while deactivating user';
END;
-- Check if admin
IF NOT is_admin(p_admin_id) THEN
SET p_success = FALSE;
SET p_message = 'Access denied: Admin privileges required';
ELSE
-- Check if user exists
IF NOT EXISTS (SELECT 1 FROM users WHERE id = p_user_id AND deleted_at IS NULL) THEN
SET p_success = FALSE;
SET p_message = 'User not found';
ELSE
-- Deactivate user
UPDATE users SET
is_active = FALSE,
updated_at = NOW()
WHERE id = p_user_id;
SET p_success = TRUE;
SET p_message = 'User deactivated successfully';
END IF;
END IF;
END ;;
DELIMITER ;
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;
/*!50003 DROP PROCEDURE IF EXISTS `get_system_statistics` */;
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
/*!50003 SET character_set_client = utf8mb4 */ ;
/*!50003 SET character_set_results = utf8mb4 */ ;
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' */ ;
DELIMITER ;;
CREATE DEFINER=`root`@`localhost` PROCEDURE `get_system_statistics`(
IN p_admin_id VARCHAR(36),
OUT p_success BOOLEAN,
OUT p_message VARCHAR(255),
OUT p_statistics JSON
)
BEGIN
DECLARE v_total_users INT DEFAULT 0;
DECLARE v_active_users INT DEFAULT 0;
DECLARE v_total_jobs INT DEFAULT 0;
DECLARE v_total_interviews INT DEFAULT 0;
DECLARE v_total_tokens_purchased INT DEFAULT 0;
DECLARE v_total_tokens_used INT DEFAULT 0;
DECLARE v_total_revenue DECIMAL(10,2) DEFAULT 0;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_success = FALSE;
SET p_message = 'An error occurred while getting statistics';
END;
-- Check if admin
IF NOT is_admin(p_admin_id) THEN
SET p_success = FALSE;
SET p_message = 'Access denied: Admin privileges required';
ELSE
-- Get statistics
SELECT COUNT(*) INTO v_total_users FROM users WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO v_active_users FROM users WHERE is_active = TRUE AND deleted_at IS NULL;
SELECT COALESCE(SUM(jobs_created), 0) INTO v_total_jobs FROM user_usage;
SELECT COALESCE(SUM(interviews_completed), 0) INTO v_total_interviews FROM user_usage;
SELECT COALESCE(SUM(tokens_purchased), 0) INTO v_total_tokens_purchased FROM user_usage;
SELECT COALESCE(SUM(tokens_used), 0) INTO v_total_tokens_used FROM user_usage;
SELECT COALESCE(SUM(amount), 0) INTO v_total_revenue FROM payment_records WHERE status = 'paid';
-- Build statistics JSON
SET p_statistics = JSON_OBJECT(
'total_users', v_total_users,
'active_users', v_active_users,
'total_jobs', v_total_jobs,
'total_interviews', v_total_interviews,
'total_tokens_purchased', v_total_tokens_purchased,
'total_tokens_used', v_total_tokens_used,
'total_revenue', v_total_revenue,
'generated_at', NOW()
);
SET p_success = TRUE;
SET p_message = 'Statistics retrieved successfully';
END IF;
END ;;
DELIMITER ;
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;
/*!50003 DROP PROCEDURE IF EXISTS `update_user` */;
/*!50003 SET @saved_cs_client = @@character_set_client */ ;
/*!50003 SET @saved_cs_results = @@character_set_results */ ;
/*!50003 SET @saved_col_connection = @@collation_connection */ ;
/*!50003 SET character_set_client = utf8mb4 */ ;
/*!50003 SET character_set_results = utf8mb4 */ ;
/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ;
/*!50003 SET @saved_sql_mode = @@sql_mode */ ;
/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION' */ ;
DELIMITER ;;
CREATE DEFINER=`root`@`localhost` PROCEDURE `update_user`(
IN p_user_id VARCHAR(36),
IN p_email VARCHAR(255),
IN p_first_name VARCHAR(100),
IN p_last_name VARCHAR(100),
IN p_role ENUM('admin', 'recruiter'),
IN p_company_name VARCHAR(255),
IN p_is_active BOOLEAN,
IN p_admin_id VARCHAR(36),
OUT p_success BOOLEAN,
OUT p_message VARCHAR(255)
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_success = FALSE;
SET p_message = 'An error occurred while updating user';
END;
-- Check if admin
IF NOT is_admin(p_admin_id) THEN
SET p_success = FALSE;
SET p_message = 'Access denied: Admin privileges required';
ELSE
-- Check if user exists
IF NOT EXISTS (SELECT 1 FROM users WHERE id = p_user_id AND deleted_at IS NULL) THEN
SET p_success = FALSE;
SET p_message = 'User not found';
ELSE
-- Update user
UPDATE users SET
email = p_email,
first_name = p_first_name,
last_name = p_last_name,
role = p_role,
company_name = p_company_name,
is_active = p_is_active,
updated_at = NOW()
WHERE id = p_user_id;
SET p_success = TRUE;
SET p_message = 'User updated successfully';
END IF;
END IF;
END ;;
DELIMITER ;
/*!50003 SET sql_mode = @saved_sql_mode */ ;
/*!50003 SET character_set_client = @saved_cs_client */ ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-09-16 20:22:32

View File

@ -0,0 +1,55 @@
CREATE DATABASE IF NOT EXISTS `candidb_main` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `candidb_main`;
-- MySQL dump 10.13 Distrib 8.0.38, for Win64 (x86_64)
--
-- Host: localhost Database: candidb_main
-- ------------------------------------------------------
-- Server version 8.0.39
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `token_packages`
--
DROP TABLE IF EXISTS `token_packages`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `token_packages` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`description` text COLLATE utf8mb4_unicode_ci,
`quantity` int NOT NULL,
`price_per_token` decimal(10,2) NOT NULL,
`total_price` decimal(10,2) NOT NULL,
`discount_percentage` decimal(5,2) DEFAULT '0.00',
`is_popular` tinyint(1) DEFAULT '0',
`is_active` tinyint(1) DEFAULT '1',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
CONSTRAINT `chk_token_packages_discount_valid` CHECK (((`discount_percentage` >= 0) and (`discount_percentage` <= 100))),
CONSTRAINT `chk_token_packages_price_positive` CHECK ((`price_per_token` > 0)),
CONSTRAINT `chk_token_packages_quantity_positive` CHECK ((`quantity` > 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-09-16 20:22:32

View File

@ -0,0 +1,53 @@
CREATE DATABASE IF NOT EXISTS `candidb_main` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `candidb_main`;
-- MySQL dump 10.13 Distrib 8.0.38, for Win64 (x86_64)
--
-- Host: localhost Database: candidb_main
-- ------------------------------------------------------
-- Server version 8.0.39
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `user_usage`
--
DROP TABLE IF EXISTS `user_usage`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `user_usage` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`user_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`jobs_created` int DEFAULT '0',
`interviews_completed` int DEFAULT '0',
`tokens_purchased` int DEFAULT '0',
`tokens_used` int DEFAULT '0',
`last_reset_date` date DEFAULT (curdate()),
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_user_usage` (`user_id`),
CONSTRAINT `user_usage_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `chk_usage_positive` CHECK (((`jobs_created` >= 0) and (`interviews_completed` >= 0) and (`tokens_purchased` >= 0) and (`tokens_used` >= 0)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-09-16 20:22:31

View File

@ -0,0 +1,60 @@
CREATE DATABASE IF NOT EXISTS `candidb_main` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;
USE `candidb_main`;
-- MySQL dump 10.13 Distrib 8.0.38, for Win64 (x86_64)
--
-- Host: localhost Database: candidb_main
-- ------------------------------------------------------
-- Server version 8.0.39
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `users`
--
DROP TABLE IF EXISTS `users`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `users` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`password_hash` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`first_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`last_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`role` enum('admin','recruiter') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'recruiter',
`company_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`avatar_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`is_active` tinyint(1) DEFAULT '1',
`last_login_at` timestamp NULL DEFAULT NULL,
`email_verified_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`),
KEY `idx_email` (`email`),
KEY `idx_role` (`role`),
KEY `idx_active` (`is_active`),
KEY `idx_role_active` (`role`,`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-09-16 20:22:32

703
database/deploy_dump.sql Normal file
View File

@ -0,0 +1,703 @@
-- Auto-generated consolidated deployment SQL based on candidb_dump1
DROP DATABASE IF EXISTS candidb_main;
CREATE DATABASE IF NOT EXISTS `candidb_main`
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE `candidb_main`;
-- Core tables (ordered by dependencies)
-- users
CREATE TABLE `users` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`password_hash` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`first_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`last_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`role` enum('admin','recruiter') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'recruiter',
`company_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`avatar_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`is_active` tinyint(1) DEFAULT '1',
`last_login_at` timestamp NULL DEFAULT NULL,
`email_verified_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`),
KEY `idx_email` (`email`),
KEY `idx_role` (`role`),
KEY `idx_active` (`is_active`),
KEY `idx_role_active` (`role`,`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- token_packages
CREATE TABLE `token_packages` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`description` text COLLATE utf8mb4_unicode_ci,
`quantity` int NOT NULL,
`price_per_token` decimal(10,2) NOT NULL,
`total_price` decimal(10,2) NOT NULL,
`discount_percentage` decimal(5,2) DEFAULT '0.00',
`is_popular` tinyint(1) DEFAULT '0',
`is_active` tinyint(1) DEFAULT '1',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
CONSTRAINT `chk_token_packages_discount_valid` CHECK (((`discount_percentage` >= 0) and (`discount_percentage` <= 100))),
CONSTRAINT `chk_token_packages_price_positive` CHECK ((`price_per_token` > 0)),
CONSTRAINT `chk_token_packages_quantity_positive` CHECK ((`quantity` > 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- jobs
CREATE TABLE `jobs` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`user_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`title` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`description` text COLLATE utf8mb4_unicode_ci NOT NULL,
`requirements` text COLLATE utf8mb4_unicode_ci NOT NULL,
`skills_required` json DEFAULT NULL,
`location` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`employment_type` enum('full_time','part_time','contract','internship') COLLATE utf8mb4_unicode_ci DEFAULT 'full_time',
`experience_level` enum('entry','mid','senior','lead','executive') COLLATE utf8mb4_unicode_ci DEFAULT 'mid',
`salary_min` decimal(10,2) DEFAULT NULL,
`salary_max` decimal(10,2) DEFAULT NULL,
`currency` varchar(3) COLLATE utf8mb4_unicode_ci DEFAULT 'USD',
`status` enum('draft','active','paused','closed') COLLATE utf8mb4_unicode_ci DEFAULT 'draft',
`evaluation_criteria` json DEFAULT NULL,
`interview_questions` json DEFAULT NULL,
`interview_style` enum('personal','balanced','technical') COLLATE utf8mb4_unicode_ci DEFAULT 'balanced',
`application_deadline` timestamp NULL DEFAULT NULL,
`icon` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT 'briefcase',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_status` (`user_id`,`status`),
KEY `idx_created_at` (`created_at`),
KEY `idx_jobs_user_status_created` (`user_id`,`status`,`created_at` DESC),
CONSTRAINT `jobs_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- candidates
CREATE TABLE `candidates` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`user_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`job_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`first_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`last_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`phone` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`resume_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`cover_letter` text COLLATE utf8mb4_unicode_ci,
`source` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`status` enum('applied','interviewing','evaluated','hired','rejected') COLLATE utf8mb4_unicode_ci DEFAULT 'applied',
`applied_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`last_activity_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_candidate_per_job` (`job_id`,`email`),
KEY `idx_user_job` (`user_id`,`job_id`),
KEY `idx_status` (`status`),
KEY `idx_candidates_user_job_status` (`user_id`,`job_id`,`status`),
CONSTRAINT `candidates_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `candidates_ibfk_2` FOREIGN KEY (`job_id`) REFERENCES `jobs` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- interviews
CREATE TABLE `interviews` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`user_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`candidate_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`job_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`status` enum('scheduled','in_progress','completed','abandoned') COLLATE utf8mb4_unicode_ci DEFAULT 'scheduled',
`started_at` timestamp NULL DEFAULT NULL,
`completed_at` timestamp NULL DEFAULT NULL,
`duration_minutes` int DEFAULT '0',
`ai_questions` json DEFAULT NULL,
`candidate_responses` json DEFAULT NULL,
`ai_evaluation` json DEFAULT NULL,
`overall_score` decimal(5,2) DEFAULT NULL,
`technical_score` decimal(5,2) DEFAULT NULL,
`communication_score` decimal(5,2) DEFAULT NULL,
`culture_fit_score` decimal(5,2) DEFAULT NULL,
`ai_feedback` text COLLATE utf8mb4_unicode_ci,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `token` (`token`),
KEY `candidate_id` (`candidate_id`),
KEY `job_id` (`job_id`),
KEY `idx_token` (`token`),
KEY `idx_user_candidate` (`user_id`,`candidate_id`),
KEY `idx_status` (`status`),
KEY `idx_interviews_user_status` (`user_id`,`status`),
KEY `idx_interviews_token_status` (`token`,`status`),
CONSTRAINT `interviews_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `interviews_ibfk_2` FOREIGN KEY (`candidate_id`) REFERENCES `candidates` (`id`) ON DELETE CASCADE,
CONSTRAINT `interviews_ibfk_3` FOREIGN KEY (`job_id`) REFERENCES `jobs` (`id`) ON DELETE CASCADE,
CONSTRAINT `chk_scores_valid` CHECK ((((`overall_score` is null) or ((`overall_score` >= 0) and (`overall_score` <= 100))) and ((`technical_score` is null) or ((`technical_score` >= 0) and (`technical_score` <= 100))) and ((`communication_score` is null) or ((`communication_score` >= 0) and (`communication_score` <= 100))) and ((`culture_fit_score` is null) or ((`culture_fit_score` >= 0) and (`culture_fit_score` <= 100)))))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- interview_questions
CREATE TABLE `interview_questions` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`interview_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`question_text` text COLLATE utf8mb4_unicode_ci NOT NULL,
`question_type` enum('technical','behavioral','situational','culture_fit') COLLATE utf8mb4_unicode_ci NOT NULL,
`difficulty_level` enum('easy','medium','hard') COLLATE utf8mb4_unicode_ci DEFAULT 'medium',
`expected_answer` text COLLATE utf8mb4_unicode_ci,
`evaluation_criteria` json DEFAULT NULL,
`order_index` int NOT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_interview_order` (`interview_id`,`order_index`),
CONSTRAINT `interview_questions_ibfk_1` FOREIGN KEY (`interview_id`) REFERENCES `interviews` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- candidate_responses
CREATE TABLE `candidate_responses` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`interview_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`question_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`response_text` text COLLATE utf8mb4_unicode_ci NOT NULL,
`response_audio_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`ai_score` decimal(5,2) DEFAULT NULL,
`ai_feedback` text COLLATE utf8mb4_unicode_ci,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_response_per_question` (`interview_id`,`question_id`),
KEY `question_id` (`question_id`),
CONSTRAINT `candidate_responses_ibfk_1` FOREIGN KEY (`interview_id`) REFERENCES `interviews` (`id`) ON DELETE CASCADE,
CONSTRAINT `candidate_responses_ibfk_2` FOREIGN KEY (`question_id`) REFERENCES `interview_questions` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- interview_tokens
CREATE TABLE `interview_tokens` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`user_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`token_type` enum('single','bulk') COLLATE utf8mb4_unicode_ci NOT NULL,
`quantity` int NOT NULL DEFAULT '1',
`price_per_token` decimal(10,2) NOT NULL,
`total_price` decimal(10,2) NOT NULL,
`tokens_used` int DEFAULT '0',
`tokens_remaining` int GENERATED ALWAYS AS ((`quantity` - `tokens_used`)) STORED,
`status` enum('active','exhausted','expired') COLLATE utf8mb4_unicode_ci DEFAULT 'active',
`expires_at` timestamp NULL DEFAULT NULL,
`purchased_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_status` (`user_id`,`status`),
KEY `idx_expires_at` (`expires_at`),
KEY `idx_interview_tokens_user_active` (`user_id`,`status`),
CONSTRAINT `interview_tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `chk_interview_tokens_quantity_positive` CHECK ((`quantity` > 0)),
CONSTRAINT `chk_interview_tokens_used_valid` CHECK (((`tokens_used` >= 0) and (`tokens_used` <= `quantity`)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- payment_records
CREATE TABLE `payment_records` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`user_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`token_package_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`amount` decimal(10,2) NOT NULL,
`currency` varchar(3) COLLATE utf8mb4_unicode_ci DEFAULT 'USD',
`status` enum('pending','paid','failed','refunded','cancelled') COLLATE utf8mb4_unicode_ci DEFAULT 'pending',
`payment_method` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`payment_reference` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`invoice_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`paid_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `token_package_id` (`token_package_id`),
KEY `idx_user_status` (`user_id`,`status`),
KEY `idx_payment_reference` (`payment_reference`),
KEY `idx_payment_records_user_created` (`user_id`,`created_at` DESC),
CONSTRAINT `payment_records_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `payment_records_ibfk_2` FOREIGN KEY (`token_package_id`) REFERENCES `token_packages` (`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- user_usage
CREATE TABLE `user_usage` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`user_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`jobs_created` int DEFAULT '0',
`interviews_completed` int DEFAULT '0',
`tokens_purchased` int DEFAULT '0',
`tokens_used` int DEFAULT '0',
`last_reset_date` date DEFAULT (curdate()),
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_user_usage` (`user_id`),
CONSTRAINT `user_usage_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `chk_usage_positive` CHECK (((`jobs_created` >= 0) and (`interviews_completed` >= 0) and (`tokens_purchased` >= 0) and (`tokens_used` >= 0)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- audit_logs
CREATE TABLE `audit_logs` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`user_id` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`action` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
`resource_type` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`resource_id` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`old_values` json DEFAULT NULL,
`new_values` json DEFAULT NULL,
`ip_address` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`user_agent` text COLLATE utf8mb4_unicode_ci,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_action` (`user_id`,`action`),
KEY `idx_resource` (`resource_type`,`resource_id`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `audit_logs_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- job_links
CREATE TABLE `job_links` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`job_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`url_slug` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`tokens_available` int DEFAULT '0',
`tokens_used` int DEFAULT '0',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `url_slug` (`url_slug`),
KEY `idx_job_id` (`job_id`),
KEY `idx_url_slug` (`url_slug`),
CONSTRAINT `job_links_ibfk_1` FOREIGN KEY (`job_id`) REFERENCES `jobs` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- conversation_messages
CREATE TABLE `conversation_messages` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
`interview_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`link_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`sender` enum('candidate','ai') COLLATE utf8mb4_unicode_ci NOT NULL,
`message` text COLLATE utf8mb4_unicode_ci NOT NULL,
`message_data` json DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_interview_id` (`interview_id`),
KEY `idx_link_id` (`link_id`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `conversation_messages_ibfk_1` FOREIGN KEY (`interview_id`) REFERENCES `interviews` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- interview_events
CREATE TABLE `interview_events` (
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`job_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`link_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
`event_type` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
`event_data` json DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_job_id` (`job_id`),
KEY `idx_link_id` (`link_id`),
KEY `idx_event_type` (`event_type`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `interview_events_ibfk_1` FOREIGN KEY (`job_id`) REFERENCES `jobs` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Triggers
DELIMITER $$
CREATE TRIGGER `update_job_usage_after_insert` AFTER INSERT ON `jobs` FOR EACH ROW BEGIN
INSERT INTO user_usage (user_id, jobs_created)
VALUES (NEW.user_id, 1)
ON DUPLICATE KEY UPDATE jobs_created = jobs_created + 1;
END$$
DELIMITER ;
DELIMITER $$
CREATE TRIGGER `update_token_usage_after_purchase` AFTER INSERT ON `interview_tokens` FOR EACH ROW BEGIN
INSERT INTO user_usage (user_id, tokens_purchased)
VALUES (NEW.user_id, NEW.quantity)
ON DUPLICATE KEY UPDATE tokens_purchased = tokens_purchased + NEW.quantity;
END$$
DELIMITER ;
DELIMITER $$
CREATE TRIGGER `update_interview_usage_after_complete` AFTER UPDATE ON `interviews` FOR EACH ROW BEGIN
IF OLD.status != 'completed' AND NEW.status = 'completed' THEN
INSERT INTO user_usage (user_id, interviews_completed, tokens_used)
VALUES (NEW.user_id, 1, 1)
ON DUPLICATE KEY UPDATE
interviews_completed = interviews_completed + 1,
tokens_used = tokens_used + 1;
END IF;
END$$
DELIMITER ;
-- Functions
DELIMITER $$
CREATE FUNCTION `can_create_job`(user_uuid VARCHAR(36)) RETURNS tinyint(1)
READS SQL DATA
DETERMINISTIC
BEGIN
DECLARE current_jobs INT DEFAULT 0;
DECLARE max_jobs INT DEFAULT 100;
SELECT COALESCE(jobs_created, 0) INTO current_jobs FROM user_usage WHERE user_id = user_uuid;
RETURN current_jobs < max_jobs;
END$$
DELIMITER ;
DELIMITER $$
CREATE FUNCTION `get_all_users`() RETURNS json
READS SQL DATA
DETERMINISTIC
BEGIN
DECLARE result JSON;
SELECT JSON_ARRAYAGG(
JSON_OBJECT(
'id', id,
'email', email,
'first_name', first_name,
'last_name', last_name,
'role', role,
'company_name', company_name,
'is_active', is_active,
'last_login_at', last_login_at,
'email_verified_at', email_verified_at,
'created_at', created_at
)
) INTO result
FROM users
WHERE deleted_at IS NULL
ORDER BY created_at DESC;
RETURN result;
END$$
DELIMITER ;
DELIMITER $$
CREATE FUNCTION `get_token_usage_summary`(user_uuid VARCHAR(36)) RETURNS json
READS SQL DATA
DETERMINISTIC
BEGIN
DECLARE total_purchased INT DEFAULT 0;
DECLARE total_used INT DEFAULT 0;
DECLARE total_available INT DEFAULT 0;
DECLARE result JSON;
SELECT COALESCE(SUM(quantity), 0) INTO total_purchased FROM interview_tokens WHERE user_id = user_uuid;
SELECT COALESCE(SUM(tokens_used), 0) INTO total_used FROM interview_tokens WHERE user_id = user_uuid;
SELECT COALESCE(SUM(tokens_remaining), 0) INTO total_available FROM interview_tokens WHERE user_id = user_uuid AND status = 'active' AND (expires_at IS NULL OR expires_at > NOW());
SET result = JSON_OBJECT(
'total_purchased', total_purchased,
'total_used', total_used,
'total_available', total_available,
'utilization_percentage', CASE WHEN total_purchased > 0 THEN ROUND((total_used / total_purchased) * 100, 2) ELSE 0 END
);
RETURN result;
END$$
DELIMITER ;
DELIMITER $$
CREATE FUNCTION `get_user_statistics`(user_uuid VARCHAR(36)) RETURNS json
READS SQL DATA
DETERMINISTIC
BEGIN
DECLARE result JSON;
DECLARE user_usage_data JSON;
DECLARE token_summary JSON;
SELECT JSON_OBJECT(
'jobs_created', COALESCE(jobs_created, 0),
'interviews_completed', COALESCE(interviews_completed, 0),
'tokens_purchased', COALESCE(tokens_purchased, 0),
'tokens_used', COALESCE(tokens_used, 0)
) INTO user_usage_data FROM user_usage WHERE user_id = user_uuid;
SELECT get_token_usage_summary(user_uuid) INTO token_summary;
SET result = JSON_OBJECT('usage', user_usage_data, 'tokens', token_summary);
RETURN result;
END$$
DELIMITER ;
DELIMITER $$
CREATE FUNCTION `has_available_tokens`(user_uuid VARCHAR(36)) RETURNS tinyint(1)
READS SQL DATA
DETERMINISTIC
BEGIN
DECLARE available_tokens INT DEFAULT 0;
SELECT COALESCE(SUM(tokens_remaining), 0) INTO available_tokens FROM interview_tokens WHERE user_id = user_uuid AND status = 'active' AND (expires_at IS NULL OR expires_at > NOW());
RETURN available_tokens > 0;
END$$
DELIMITER ;
DELIMITER $$
CREATE FUNCTION `is_admin`(user_uuid VARCHAR(36)) RETURNS tinyint(1)
READS SQL DATA
DETERMINISTIC
BEGIN
DECLARE user_role VARCHAR(20) DEFAULT NULL;
SELECT role INTO user_role FROM users WHERE id = user_uuid AND is_active = TRUE;
RETURN user_role = 'admin';
END$$
DELIMITER ;
-- Procedures (from dump routines)
DELIMITER $$
CREATE PROCEDURE `add_tokens_to_user`(
IN p_user_id VARCHAR(36),
IN p_quantity INT,
IN p_price_per_token DECIMAL(10,2),
IN p_admin_id VARCHAR(36),
OUT p_success BOOLEAN,
OUT p_message VARCHAR(255)
)
BEGIN
DECLARE v_total_price DECIMAL(10,2);
DECLARE v_token_id VARCHAR(36);
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_success = FALSE;
SET p_message = 'An error occurred while adding tokens';
END;
IF NOT is_admin(p_admin_id) THEN
SET p_success = FALSE;
SET p_message = 'Access denied: Admin privileges required';
ELSE
IF NOT EXISTS (SELECT 1 FROM users WHERE id = p_user_id AND deleted_at IS NULL) THEN
SET p_success = FALSE;
SET p_message = 'User not found';
ELSE
SET v_total_price = p_quantity * p_price_per_token;
SET v_token_id = UUID();
INSERT INTO interview_tokens (
id, user_id, token_type, quantity, price_per_token,
total_price, status, purchased_at
) VALUES (
v_token_id, p_user_id,
CASE WHEN p_quantity = 1 THEN 'single' ELSE 'bulk' END,
p_quantity, p_price_per_token, v_total_price,
'active', NOW()
);
-- NOTE: routines in dump referenced interview_token_id; schema doesn't have it. Keeping minimal insert
INSERT INTO payment_records (
user_id, token_package_id, amount, status, payment_method, payment_reference
) VALUES (
p_user_id, NULL, v_total_price,
'paid', 'admin_granted', CONCAT('ADMIN_', p_admin_id, '_', NOW())
);
SET p_success = TRUE;
SET p_message = CONCAT('Successfully added ', p_quantity, ' tokens to user');
END IF;
END IF;
END$$
DELIMITER ;
DELIMITER $$
CREATE PROCEDURE `change_user_password`(
IN p_user_id VARCHAR(36),
IN p_new_password_hash VARCHAR(255),
IN p_admin_id VARCHAR(36),
OUT p_success BOOLEAN,
OUT p_message VARCHAR(255)
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_success = FALSE;
SET p_message = 'An error occurred while changing password';
END;
IF NOT is_admin(p_admin_id) THEN
SET p_success = FALSE;
SET p_message = 'Access denied: Admin privileges required';
ELSE
IF NOT EXISTS (SELECT 1 FROM users WHERE id = p_user_id AND deleted_at IS NULL) THEN
SET p_success = FALSE;
SET p_message = 'User not found';
ELSE
UPDATE users SET
password_hash = p_new_password_hash,
updated_at = NOW()
WHERE id = p_user_id;
SET p_success = TRUE;
SET p_message = 'Password changed successfully';
END IF;
END IF;
END$$
DELIMITER ;
DELIMITER $$
CREATE PROCEDURE `create_user`(
IN p_email VARCHAR(255),
IN p_password_hash VARCHAR(255),
IN p_first_name VARCHAR(100),
IN p_last_name VARCHAR(100),
IN p_role ENUM('admin', 'recruiter'),
IN p_company_name VARCHAR(255),
IN p_admin_id VARCHAR(36),
OUT p_user_id VARCHAR(36),
OUT p_success BOOLEAN,
OUT p_message VARCHAR(255)
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_success = FALSE;
SET p_message = 'An error occurred while creating user';
END;
IF NOT is_admin(p_admin_id) THEN
SET p_success = FALSE;
SET p_message = 'Access denied: Admin privileges required';
ELSE
IF EXISTS (SELECT 1 FROM users WHERE email = p_email AND deleted_at IS NULL) THEN
SET p_success = FALSE;
SET p_message = 'Email already exists';
ELSE
SET p_user_id = UUID();
INSERT INTO users (
id, email, password_hash, first_name, last_name,
role, company_name, is_active, email_verified_at
) VALUES (
p_user_id, p_email, p_password_hash, p_first_name, p_last_name,
p_role, p_company_name, TRUE, NOW()
);
INSERT INTO user_usage (user_id) VALUES (p_user_id);
SET p_success = TRUE;
SET p_message = 'User created successfully';
END IF;
END IF;
END$$
DELIMITER ;
DELIMITER $$
CREATE PROCEDURE `deactivate_user`(
IN p_user_id VARCHAR(36),
IN p_admin_id VARCHAR(36),
OUT p_success BOOLEAN,
OUT p_message VARCHAR(255)
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_success = FALSE;
SET p_message = 'An error occurred while deactivating user';
END;
IF NOT is_admin(p_admin_id) THEN
SET p_success = FALSE;
SET p_message = 'Access denied: Admin privileges required';
ELSE
IF NOT EXISTS (SELECT 1 FROM users WHERE id = p_user_id AND deleted_at IS NULL) THEN
SET p_success = FALSE;
SET p_message = 'User not found';
ELSE
UPDATE users SET
is_active = FALSE,
updated_at = NOW()
WHERE id = p_user_id;
SET p_success = TRUE;
SET p_message = 'User deactivated successfully';
END IF;
END IF;
END$$
DELIMITER ;
DELIMITER $$
CREATE PROCEDURE `get_system_statistics`(
IN p_admin_id VARCHAR(36),
OUT p_success BOOLEAN,
OUT p_message VARCHAR(255),
OUT p_statistics JSON
)
BEGIN
DECLARE v_total_users INT DEFAULT 0;
DECLARE v_active_users INT DEFAULT 0;
DECLARE v_total_jobs INT DEFAULT 0;
DECLARE v_total_interviews INT DEFAULT 0;
DECLARE v_total_tokens_purchased INT DEFAULT 0;
DECLARE v_total_tokens_used INT DEFAULT 0;
DECLARE v_total_revenue DECIMAL(10,2) DEFAULT 0;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_success = FALSE;
SET p_message = 'An error occurred while getting statistics';
END;
IF NOT is_admin(p_admin_id) THEN
SET p_success = FALSE;
SET p_message = 'Access denied: Admin privileges required';
ELSE
SELECT COUNT(*) INTO v_total_users FROM users WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO v_active_users FROM users WHERE is_active = TRUE AND deleted_at IS NULL;
SELECT COALESCE(SUM(jobs_created), 0) INTO v_total_jobs FROM user_usage;
SELECT COALESCE(SUM(interviews_completed), 0) INTO v_total_interviews FROM user_usage;
SELECT COALESCE(SUM(tokens_purchased), 0) INTO v_total_tokens_purchased FROM user_usage;
SELECT COALESCE(SUM(tokens_used), 0) INTO v_total_tokens_used FROM user_usage;
SELECT COALESCE(SUM(amount), 0) INTO v_total_revenue FROM payment_records WHERE status = 'paid';
SET p_statistics = JSON_OBJECT(
'total_users', v_total_users,
'active_users', v_active_users,
'total_jobs', v_total_jobs,
'total_interviews', v_total_interviews,
'total_tokens_purchased', v_total_tokens_purchased,
'total_tokens_used', v_total_tokens_used,
'total_revenue', v_total_revenue,
'generated_at', NOW()
);
SET p_success = TRUE;
SET p_message = 'Statistics retrieved successfully';
END IF;
END$$
DELIMITER ;
DELIMITER $$
CREATE PROCEDURE `update_user`(
IN p_user_id VARCHAR(36),
IN p_email VARCHAR(255),
IN p_first_name VARCHAR(100),
IN p_last_name VARCHAR(100),
IN p_role ENUM('admin', 'recruiter'),
IN p_company_name VARCHAR(255),
IN p_is_active BOOLEAN,
IN p_admin_id VARCHAR(36),
OUT p_success BOOLEAN,
OUT p_message VARCHAR(255)
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_success = FALSE;
SET p_message = 'An error occurred while updating user';
END;
IF NOT is_admin(p_admin_id) THEN
SET p_success = FALSE;
SET p_message = 'Access denied: Admin privileges required';
ELSE
IF NOT EXISTS (SELECT 1 FROM users WHERE id = p_user_id AND deleted_at IS NULL) THEN
SET p_success = FALSE;
SET p_message = 'User not found';
ELSE
UPDATE users SET
email = p_email,
first_name = p_first_name,
last_name = p_last_name,
role = p_role,
company_name = p_company_name,
is_active = p_is_active,
updated_at = NOW()
WHERE id = p_user_id;
SET p_success = TRUE;
SET p_message = 'User updated successfully';
END IF;
END IF;
END$$
DELIMITER ;
-- Insert default admin user (password: admin123 - CHANGE THIS!)
INSERT INTO users (id, email, password_hash, first_name, last_name, role, is_active, email_verified_at) VALUES
(UUID(), 'admin@candivista.com', '$2b$10$rcKrXbkDjjjT3vA3kMH78OkyUFNTn6nuCsqK90JEA2.S2p0dVjFUi', 'Admin', 'User', 'admin', TRUE, NOW());

View File

@ -1,13 +1,191 @@
version: '3.8'
services:
aisapp2:
build: .
ports:
- "5000:80"
working_dir: /app
command: ["dotnet", "run", "--project", "AISApp", "--urls", "http://0.0.0.0:80"]
volumes:
- .:/app
# Database Service
database:
build:
context: ./database
dockerfile: Dockerfile
image: candidat/database:${APP_VERSION:-latest}
container_name: candidat-database
environment:
- ASPNETCORE_ENVIRONMENT=Development
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
ports:
- "${DB_PORT:-3307}:3306"
- "${DB_X_PORT:-33061}:33060" # MySQL X Protocol for development
volumes:
- db_data:/var/lib/mysql
networks:
- candidat-network
restart: unless-stopped
command: --default-authentication-plugin=mysql_native_password
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
deploy:
resources:
limits:
memory: 1G
reservations:
memory: 512M
# Backend Service
backend:
build:
context: ./backend
dockerfile: Dockerfile
image: candidat/backend:${APP_VERSION:-latest}
container_name: candidat-backend
environment:
NODE_ENV: ${NODE_ENV:-production}
DB_HOST: database
DB_PORT: 3306
DB_NAME: ${MYSQL_DATABASE}
DB_USER: ${MYSQL_USER}
DB_PASSWORD: ${MYSQL_PASSWORD}
AI_PROVIDER: ${AI_PROVIDER}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
OPENROUTER_MODEL: ${OPENROUTER_MODEL}
OPENROUTER_BASE_URL: ${OPENROUTER_BASE_URL}
OPENROUTER_REL_PATH: ${OPENROUTER_REL_PATH}
OPENROUTER_TEMPERATURE: ${OPENROUTER_TEMPERATURE}
AI_PORT: ${AI_PORT}
AI_MODEL: ${AI_MODEL}
ports:
- "${BACKEND_PORT:-8083}:8083"
volumes:
# Development hot reloading (only if NODE_ENV=development)
- ./backend/src:/app/src:ro
depends_on:
database:
condition: service_healthy
networks:
- candidat-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8083/rest/ai/test-ai"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
# Frontend Service
frontend:
build:
context: ./frontend/candidat-frontend
dockerfile: Dockerfile
image: candidat/frontend:${APP_VERSION:-latest}
container_name: candidat-frontend
environment:
NODE_ENV: ${NODE_ENV:-production}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
ports:
- "${FRONTEND_PORT:-3000}:3000"
volumes:
# Development hot reloading (only if NODE_ENV=development)
- ./frontend/candidat-frontend/src:/app/src:ro
depends_on:
backend:
condition: service_healthy
networks:
- candidat-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
deploy:
resources:
limits:
memory: 256M
reservations:
memory: 128M
# Chatbot Service
chatbot:
build:
context: ./tuna/tuna
dockerfile: Dockerfile
image: candidat/chatbot:${APP_VERSION:-latest}
container_name: candidat-chatbot
environment:
ASPNETCORE_ENVIRONMENT: ${NODE_ENV:-production}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
CHATBOT_DB_HOST: database
CHATBOT_DB_NAME: ${MYSQL_DATABASE}
CHATBOT_DB_USER: ${MYSQL_USER}
CHATBOT_DB_PASSWORD: ${MYSQL_PASSWORD}
CHATBOT_DB_PORT: 3306
ports:
- "${CHATBOT_PORT:-5000}:80"
depends_on:
database:
condition: service_healthy
networks:
- candidat-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/api/chat"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
# Nginx Reverse Proxy
nginx:
image: nginx:alpine
container_name: candidat-nginx
ports:
- "${NGINX_PORT:-80}:80"
- "${NGINX_SSL_PORT:-443}:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- nginx_logs:/var/log/nginx
depends_on:
- frontend
- backend
- chatbot
networks:
- candidat-network
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
deploy:
resources:
limits:
memory: 128M
reservations:
memory: 64M
volumes:
db_data:
driver: local
nginx_logs:
driver: local
networks:
candidat-network:
driver: bridge

38
env.cloudflare Normal file
View File

@ -0,0 +1,38 @@
# Cloudflare Environment Configuration for VPS
APP_VERSION=1.0.0
NODE_ENV=production
# Database (Docker)
MYSQL_ROOT_PASSWORD=your_secure_root_password_here
MYSQL_DATABASE=candidb_main
MYSQL_USER=candidat
MYSQL_PASSWORD=your_secure_db_password_here
# Database ports (internal only, not exposed externally)
DB_PORT=3306
DB_X_PORT=33060
# Application URLs and Ports (Cloudflare handles SSL termination)
NEXT_PUBLIC_API_URL=https://candivista.com
BACKEND_PORT=8083
FRONTEND_PORT=3000
NGINX_PORT=80
NGINX_SSL_PORT=443
# AI Configuration
AI_PROVIDER=openrouter
OPENROUTER_API_KEY=your_openrouter_api_key_here
OPENROUTER_MODEL=gemma
OPENROUTER_BASE_URL=openrouter.ai
OPENROUTER_REL_PATH=/api
OPENROUTER_TEMPERATURE=0.7
# Fallback AI (if needed)
AI_PORT=11434
AI_MODEL=gpt-oss:20b
# Chatbot Service Configuration
CHATBOT_SERVICE_URL=http://chatbot:80
CHATBOT_SERVICE_TIMEOUT=30000
CHATBOT_FALLBACK_ENABLED=true
CHATBOT_PORT=5000

38
env.example Normal file
View File

@ -0,0 +1,38 @@
# Environment
APP_VERSION=1.0.0
NODE_ENV=development
# Database (Docker)
MYSQL_ROOT_PASSWORD=musicisoverrated
MYSQL_DATABASE=candidb_main
MYSQL_USER=candidat
MYSQL_PASSWORD=StrongLocalDevPass123
DB_PORT=3306
DB_X_PORT=33060
# Application URLs and Ports
NEXT_PUBLIC_API_URL=https://candivista.com
BACKEND_PORT=8083
FRONTEND_PORT=3000
NGINX_PORT=80
NGINX_SSL_PORT=443
# AI Configuration
AI_PROVIDER=openrouter
OPENROUTER_API_KEY="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"
OPENROUTER_MODEL=gemma # or any model from your predefined list
OPENROUTER_BASE_URL=openrouter.ai
OPENROUTER_REL_PATH=/api
OPENROUTER_TEMPERATURE=0.7
# Fallback AI (if needed)
AI_PORT=11434
AI_MODEL=gpt-oss:20b
# Chatbot Service Configuration
CHATBOT_SERVICE_URL=http://chatbot:80
CHATBOT_SERVICE_TIMEOUT=30000
CHATBOT_FALLBACK_ENABLED=true
CHATBOT_PORT=5000

38
env.production Normal file
View File

@ -0,0 +1,38 @@
# Production Environment Configuration for VPS
APP_VERSION=1.0.0
NODE_ENV=production
# Database (Docker)
MYSQL_ROOT_PASSWORD=your_secure_root_password_here
MYSQL_DATABASE=candidb_main
MYSQL_USER=candidat
MYSQL_PASSWORD=your_secure_db_password_here
# Database ports (internal only, not exposed externally)
DB_PORT=3306
DB_X_PORT=33060
# Application URLs and Ports
NEXT_PUBLIC_API_URL=https://candivista.com
BACKEND_PORT=8083
FRONTEND_PORT=3000
NGINX_PORT=80
NGINX_SSL_PORT=443
# AI Configuration
AI_PROVIDER=openrouter
OPENROUTER_API_KEY=your_openrouter_api_key_here
OPENROUTER_MODEL=gemma
OPENROUTER_BASE_URL=openrouter.ai
OPENROUTER_REL_PATH=/api
OPENROUTER_TEMPERATURE=0.7
# Fallback AI (if needed)
AI_PORT=11434
AI_MODEL=gpt-oss:20b
# Chatbot Service Configuration
CHATBOT_SERVICE_URL=http://chatbot:80
CHATBOT_SERVICE_TIMEOUT=30000
CHATBOT_FALLBACK_ENABLED=true
CHATBOT_PORT=5000

13
frontend/.dockerignore Normal file
View File

@ -0,0 +1,13 @@
node_modules
.next
.git
.gitignore
README.md
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.DS_Store
*.log

41
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

55
frontend/Dockerfile Normal file
View File

@ -0,0 +1,55 @@
# Multi-stage build for Next.js
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the application
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Install curl for health checks
RUN apk add --no-cache curl
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000 || exit 1
CMD ["node", "server.js"]

258
frontend/FRONTEND_README.md Normal file
View File

@ -0,0 +1,258 @@
# Candivista Frontend - Modern AI Recruitment Platform
## 🎨 **Beautiful, Modern Frontend**
A stunning, responsive frontend built with Next.js 15, TypeScript, and Tailwind CSS that showcases the complete Candivista AI-powered recruitment platform.
## ✨ **Key Features**
### 🎯 **Modern Design**
- **Gradient animations** and smooth transitions
- **Glass morphism effects** with backdrop blur
- **Interactive hover animations** and micro-interactions
- **Responsive design** for all devices
- **Custom CSS animations** for enhanced UX
### 🚀 **Performance Optimized**
- **Next.js 15** with App Router
- **TypeScript** for type safety
- **Tailwind CSS** for utility-first styling
- **Optimized images** and lazy loading
- **Smooth scrolling** and navigation
### 🎭 **Interactive Components**
- **AnimatedCounter** - Smooth number animations
- **FeatureCard** - Hover effects and gradients
- **PricingCard** - Interactive pricing plans
- **TechStackCard** - Technology showcase
## 🏗️ **Project Structure**
```
frontend/candidat-frontend/
├── src/
│ ├── app/
│ │ ├── page.tsx # Main landing page
│ │ ├── globals.css # Global styles & animations
│ │ ├── layout.tsx # Root layout
│ │ └── favicon.ico
│ └── components/
│ ├── AnimatedCounter.tsx # Animated number counter
│ ├── FeatureCard.tsx # Feature showcase card
│ ├── PricingCard.tsx # Pricing plan card
│ └── TechStackCard.tsx # Technology stack card
├── public/ # Static assets
├── package.json
├── next.config.js
├── tailwind.config.js
└── tsconfig.json
```
## 🎨 **Design System**
### **Color Palette**
- **Primary**: Blue (#3B82F6) to Indigo (#6366F1)
- **Secondary**: Purple (#8B5CF6) to Pink (#EC4899)
- **Accent**: Green (#10B981) for success states
- **Neutral**: Gray scale for text and backgrounds
### **Typography**
- **Headings**: Bold, large sizes with gradient text
- **Body**: Clean, readable font with proper line height
- **Responsive**: Scales appropriately on all devices
### **Animations**
- **Gradient animations** for text and backgrounds
- **Hover effects** with scale and shadow transitions
- **Smooth scrolling** between sections
- **Loading animations** with custom spinners
## 🚀 **Getting Started**
### **Prerequisites**
- Node.js 18+
- npm or yarn
- Next.js 15
### **Installation**
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Start production server
npm start
```
### **Development**
```bash
# Run with hot reload
npm run dev
# Type checking
npm run type-check
# Linting
npm run lint
```
## 🎯 **Key Sections**
### **1. Hero Section**
- **Compelling headline** with gradient text animation
- **Clear value proposition** for AI recruitment
- **Call-to-action buttons** with hover effects
- **Interactive illustration** showing the workflow
### **2. Features Section**
- **Multi-tenant architecture** explanation
- **Flexible link system** showcase
- **AI-powered intelligence** highlights
- **Visual dashboard mockup** with animations
### **3. Pricing Section**
- **Token-based pricing** with clear tiers
- **Interactive pricing cards** with hover effects
- **Feature comparison** for each plan
- **Popular plan highlighting**
### **4. Technology Stack**
- **Modern tech showcase** with icons
- **Hover animations** for each technology
- **Performance benefits** explanation
- **Developer experience** highlights
### **5. Stats Section**
- **Animated counters** showing platform success
- **Trust indicators** for credibility
- **Social proof** elements
### **6. Call-to-Action**
- **Compelling final CTA** with gradient background
- **Multiple action options** for different users
- **Urgency and value** messaging
## 🎨 **Custom Animations**
### **CSS Animations**
```css
/* Gradient text animation */
.animate-gradient-x {
animation: gradient-x 3s ease infinite;
}
/* Floating animation */
.animate-float {
animation: float 6s ease-in-out infinite;
}
/* Pulse glow effect */
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
```
### **Component Animations**
- **Staggered animations** for feature cards
- **Hover transformations** for interactive elements
- **Smooth transitions** between states
- **Loading states** with custom spinners
## 📱 **Responsive Design**
### **Breakpoints**
- **Mobile**: 320px - 768px
- **Tablet**: 768px - 1024px
- **Desktop**: 1024px+
### **Mobile Optimizations**
- **Touch-friendly** button sizes
- **Optimized typography** for small screens
- **Swipe gestures** for carousels
- **Fast loading** on mobile networks
## 🎯 **Performance Features**
### **Optimization**
- **Image optimization** with Next.js Image component
- **Code splitting** for faster loading
- **Lazy loading** for below-the-fold content
- **Minimal bundle size** with tree shaking
### **SEO Ready**
- **Semantic HTML** structure
- **Meta tags** for social sharing
- **Structured data** for search engines
- **Fast loading** for better rankings
## 🔧 **Customization**
### **Theming**
- **CSS variables** for easy color changes
- **Tailwind config** for design system
- **Component props** for flexibility
- **Dark mode** support ready
### **Content Management**
- **Easy text updates** in components
- **Image replacement** in public folder
- **Configuration** in separate files
- **Environment variables** for API URLs
## 🚀 **Deployment**
### **Production Build**
```bash
# Build optimized production bundle
npm run build
# Start production server
npm start
```
### **Docker Support**
```dockerfile
# Multi-stage build for optimization
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package*.json ./
EXPOSE 3000
CMD ["npm", "start"]
```
## 🎉 **Result**
A stunning, modern frontend that:
- ✅ **Showcases** the complete Candivista platform
- ✅ **Engages** users with beautiful animations
- ✅ **Converts** visitors with clear value propositions
- ✅ **Performs** excellently on all devices
- ✅ **Scales** for future feature additions
The frontend perfectly represents the sophisticated AI recruitment platform with a professional, modern design that will impress users and drive conversions.
## 📞 **Support**
For questions or support regarding the frontend:
- **Documentation**: Check component READMEs
- **Issues**: Create GitHub issues
- **Contributions**: Submit pull requests
- **Contact**: Reach out to the development team
---
**Built with ❤️ using Next.js 15, TypeScript, and Tailwind CSS**

36
frontend/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

24
frontend/next.config.js Normal file
View File

@ -0,0 +1,24 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Only use standalone output for Docker builds
...(process.env.NODE_ENV === 'production' && { output: 'standalone' }),
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'https://candivista.com',
},
// Only add rewrites for production (Docker)
...(process.env.NODE_ENV === 'production' && {
async rewrites() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'https://candivista.com';
return [
{
source: '/rest/:path*',
destination: `${apiUrl}/rest/:path*`,
},
];
},
}),
}
module.exports = nextConfig

7
frontend/next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

3761
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "candivista-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@hookform/resolvers": "^5.2.1",
"axios": "^1.11.0",
"next": "15.5.2",
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
"swagger-ui-react": "^5.29.0",
"zod": "^4.1.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/swagger-ui-react": "^5.18.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

1
frontend/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
frontend/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,145 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import axios from "axios";
import AdminLayout from "../../components/AdminLayout";
import AdminDashboard from "../../components/AdminDashboard";
import UserManagement from "../../components/UserManagement";
import JobManagement from "../../components/JobManagement";
import TokenManagement from "../../components/TokenManagement";
import SystemStats from "../../components/SystemStats";
import DeveloperTools from "../../components/DeveloperTools";
interface User {
id: string;
email: string;
first_name: string;
last_name: string;
role: string;
company_name?: string;
avatar_url?: string;
is_active: boolean;
last_login_at?: string;
email_verified_at?: string;
created_at: string;
updated_at: string;
}
interface SystemStatistics {
total_users: number;
active_users: number;
total_jobs: number;
total_interviews: number;
total_tokens_purchased: number;
total_tokens_used: number;
total_revenue: number;
generated_at: string;
}
export default function AdminPage() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState("dashboard");
const [systemStats, setSystemStats] = useState<SystemStatistics | null>(null);
const router = useRouter();
useEffect(() => {
const token = localStorage.getItem("token");
if (!token) {
router.push("/login");
return;
}
// Verify token and check if user is admin
axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/auth/me`, {
headers: {
Authorization: `Bearer ${token}`
}
})
.then(response => {
const userData = response.data;
if (userData.role !== 'admin') {
router.push("/dashboard");
return;
}
setUser(userData);
// Fetch system statistics
fetchSystemStats();
})
.catch(() => {
localStorage.removeItem("token");
localStorage.removeItem("user");
router.push("/login");
})
.finally(() => {
setLoading(false);
});
}, [router]);
const fetchSystemStats = async () => {
try {
const token = localStorage.getItem("token");
const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/statistics`, {
headers: {
Authorization: `Bearer ${token}`
}
});
setSystemStats(response.data);
} catch (error) {
console.error("Failed to fetch system statistics:", error);
}
};
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
router.push("/login");
};
const handleTabChange = (tab: string) => {
setActiveTab(tab);
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading admin dashboard...</p>
</div>
</div>
);
}
const renderContent = () => {
switch (activeTab) {
case "dashboard":
return <AdminDashboard stats={systemStats} onRefresh={fetchSystemStats} />;
case "users":
return <UserManagement />;
case "jobs":
return <JobManagement />;
case "tokens":
return <TokenManagement />;
case "stats":
return <SystemStats stats={systemStats} onRefresh={fetchSystemStats} />;
case "devtools":
return <DeveloperTools />;
default:
return <AdminDashboard stats={systemStats} onRefresh={fetchSystemStats} />;
}
};
return (
<AdminLayout
user={user || undefined}
activeTab={activeTab}
onTabChange={handleTabChange}
onLogout={handleLogout}
>
{renderContent()}
</AdminLayout>
);
}

View File

@ -0,0 +1,204 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import axios from "axios";
import Layout from "../../components/Layout";
import JobsList from "../../components/JobsList";
interface User {
id: string;
email: string;
first_name: string;
last_name: string;
role: string;
company_name?: string;
avatar_url?: string;
is_active: boolean;
last_login_at?: string;
email_verified_at?: string;
created_at: string;
updated_at: string;
}
interface Job {
id: string;
title: string;
description: string;
requirements: string;
skills_required?: string[];
location?: string;
employment_type: string;
experience_level: string;
salary_min?: number;
salary_max?: number;
currency: string;
status: string;
icon?: string;
created_at: string;
updated_at: string;
// Metrics
total_interviews?: number;
interviews_completed?: number;
available_interviews?: number;
running_days?: number;
applications?: number;
}
export default function DashboardPage() {
const [user, setUser] = useState<User | null>(null);
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(true);
const [activeSidebarItem, setActiveSidebarItem] = useState("jobs");
const router = useRouter();
useEffect(() => {
console.log("Dashboard useEffect triggered");
const token = localStorage.getItem("token");
console.log("Token found:", !!token);
if (!token) {
console.log("No token, redirecting to login");
router.push("/login");
return;
}
// Verify token with backend
console.log("Verifying token with backend...");
axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/auth/me`, {
headers: {
Authorization: `Bearer ${token}`
}
})
.then(async response => {
console.log("Auth response received:", response.data);
const userData = response.data;
setUser(userData);
// Redirect admins to admin panel
if (userData.role === 'admin') {
console.log("Admin user, redirecting to admin panel");
router.push("/admin");
return;
}
// Fetch jobs from backend
try {
console.log("Fetching jobs from backend...");
const jobsResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/rest/jobs`, {
headers: {
Authorization: `Bearer ${token}`
}
});
console.log("Jobs response status:", jobsResponse.status);
if (jobsResponse.ok) {
const jobsData = await jobsResponse.json();
console.log("Jobs data received:", jobsData);
console.log("Jobs array:", jobsData.jobs);
console.log("Jobs count:", jobsData.jobs?.length || 0);
setJobs(jobsData.jobs || []);
} else {
// Silencing console usage to satisfy linter in Server Components
await jobsResponse.text().catch(() => undefined);
setJobs([]);
}
} catch (error) {
console.error("Error fetching jobs:", error);
setJobs([]);
}
})
.catch((error) => {
console.error("Auth error:", error);
localStorage.removeItem("token");
localStorage.removeItem("user");
router.push("/login");
})
.finally(() => {
console.log("Setting loading to false");
setLoading(false);
});
}, [router]);
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
router.push("/login");
};
const handleSidebarItemClick = (item: string) => {
setActiveSidebarItem(item);
// TODO: Handle navigation to different pages
console.log("Navigate to:", item);
};
const handleEditJob = (job: Job) => {
// TODO: Navigate to edit job page or open modal
console.log("Edit job:", job.id);
};
const handleDeleteJob = (job: Job) => {
// TODO: Show confirmation dialog and delete job
console.log("Delete job:", job.id);
};
const handleViewJob = (job: Job) => {
// This will be handled by the JobsList component now
console.log("View job:", job.id);
};
const refreshJobs = async () => {
try {
const token = localStorage.getItem("token");
if (!token) return;
console.log("Refreshing jobs from backend...");
const jobsResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/rest/jobs`, {
headers: {
Authorization: `Bearer ${token}`
}
});
if (jobsResponse.ok) {
const jobsData = await jobsResponse.json();
console.log("Jobs refreshed:", jobsData);
setJobs(jobsData.jobs || []);
} else {
console.error("Failed to refresh jobs:", jobsResponse.statusText);
}
} catch (error) {
console.error("Error refreshing jobs:", error);
}
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
return (
<Layout
title="Jobs"
user={user || undefined}
activeSidebarItem={activeSidebarItem}
onSidebarItemClick={handleSidebarItemClick}
onLogout={handleLogout}
>
<JobsList
jobs={jobs}
onEditJob={handleEditJob}
onDeleteJob={handleDeleteJob}
onViewJob={handleViewJob}
onRefreshJobs={refreshJobs}
/>
</Layout>
);
}

View File

@ -0,0 +1,101 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
// Dynamically import SwaggerUI to avoid SSR issues
const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false });
export default function DocsPage() {
const [swaggerSpec, setSwaggerSpec] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchSwaggerSpec = async () => {
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8083";
const response = await fetch(`${apiUrl}/doc/swagger.json`);
if (!response.ok) {
throw new Error(`Failed to fetch API spec: ${response.status}`);
}
const spec = await response.json();
setSwaggerSpec(spec);
} catch (err) {
console.error("Error fetching Swagger spec:", err);
setError(err instanceof Error ? err.message : "Failed to load API documentation");
} finally {
setLoading(false);
}
};
fetchSwaggerSpec();
}, []);
if (loading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-300">Loading API documentation...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-6">
<div className="text-red-500 text-6xl mb-4"></div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">API Documentation Unavailable</h1>
<p className="text-gray-600 dark:text-gray-300 mb-4">{error}</p>
<div className="space-y-2 text-sm text-gray-500 dark:text-gray-400">
<p>Make sure the backend is running on:</p>
<code className="block bg-gray-100 dark:bg-gray-800 p-2 rounded">
{process.env.NEXT_PUBLIC_API_URL || "http://localhost:8083"}
</code>
<p>And Swagger is available at:</p>
<code className="block bg-gray-100 dark:bg-gray-800 p-2 rounded">
/doc and /doc/swagger.json
</code>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-white dark:bg-gray-900">
<div className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">API Documentation</h1>
<p className="text-gray-600 dark:text-gray-300 mt-1">
Interactive API documentation for Candivista backend services
</p>
</div>
</div>
<div className="max-w-7xl mx-auto">
{swaggerSpec && (
<SwaggerUI
spec={swaggerSpec}
docExpansion="list"
defaultModelsExpandDepth={2}
defaultModelExpandDepth={2}
tryItOutEnabled={true}
requestInterceptor={(request) => {
const token = localStorage.getItem("token");
if (token) {
request.headers.Authorization = `Bearer ${token}`;
}
return request;
}}
/>
)}
</div>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,236 @@
@import "tailwindcss";
/* Custom animations */
@keyframes gradient-x {
0%, 100% {
background-size: 200% 200%;
background-position: left center;
}
50% {
background-size: 200% 200%;
background-position: right center;
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-20px);
}
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
}
50% {
box-shadow: 0 0 40px rgba(59, 130, 246, 0.6);
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Utility classes */
.animate-gradient-x {
animation: gradient-x 3s ease infinite;
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
.animate-slide-up {
animation: slide-up 0.6s ease-out;
}
.animate-fade-in {
animation: fade-in 0.8s ease-out;
}
.animate-scale-in {
animation: scale-in 0.5s ease-out;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, #3b82f6, #8b5cf6);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom, #2563eb, #7c3aed);
}
/* Glass morphism effect */
.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, #3b82f6, #8b5cf6, #ec4899);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Hover effects */
.hover-lift {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.hover-lift:hover {
transform: translateY(-5px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
/* Custom button styles */
.btn-primary {
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
transition: all 0.3s ease;
}
.btn-primary:hover {
background: linear-gradient(135deg, #2563eb, #7c3aed);
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.3);
}
/* Card hover effects */
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-8px);
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
}
/* Loading animation */
.loading-dots {
display: inline-block;
}
.loading-dots::after {
content: '';
animation: loading-dots 1.5s infinite;
}
@keyframes loading-dots {
0%, 20% {
content: '';
}
40% {
content: '.';
}
60% {
content: '..';
}
80%, 100% {
content: '...';
}
}
/* Responsive text */
@media (max-width: 640px) {
.hero-title {
font-size: 3rem;
line-height: 1.1;
}
}
@media (min-width: 641px) {
.hero-title {
font-size: 4rem;
line-height: 1.1;
}
}
@media (min-width: 1024px) {
.hero-title {
font-size: 5rem;
line-height: 1.1;
}
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Focus styles */
.focus-ring:focus {
outline: none;
ring: 2px;
ring-color: #3b82f6;
ring-offset: 2px;
}
/* Custom selection */
::selection {
background: rgba(59, 130, 246, 0.2);
color: #1e40af;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.dark-mode-text {
color: #f8fafc;
}
.dark-mode-bg {
background: #0f172a;
}
}
/* Print styles */
@media print {
.no-print {
display: none !important;
}
}

View File

@ -0,0 +1,275 @@
"use client";
import { useEffect, useState, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import ConsentScreen from "../../components/ConsentScreen";
import NameInputScreen from "../../components/NameInputScreen";
import MandatoryQuestionsScreen from "../../components/MandatoryQuestionsScreen";
import ChatScreen from "../../components/ChatScreen";
interface Job {
id: string;
title: string;
description: string;
requirements: string;
skills_required?: string[];
location?: string;
employment_type: string;
experience_level: string;
salary_min?: number;
salary_max?: number;
currency: string;
status: string;
icon?: string;
created_at: string;
updated_at: string;
}
interface InterviewState {
step: 'loading' | 'consent' | 'name_input' | 'mandatory_questions' | 'chat' | 'completed' | 'error';
job: Job | null;
candidateName: string;
error: string | null;
consentGiven: boolean;
mandatoryAnswers: string[];
}
function InterviewPageInner() {
const searchParams = useSearchParams();
const linkId = searchParams.get('id');
const isTestMode = searchParams.get('test') === 'true';
const [state, setState] = useState<InterviewState>({
step: 'loading',
job: null,
candidateName: '',
error: null,
consentGiven: false,
mandatoryAnswers: []
});
useEffect(() => {
if (linkId) {
fetchJobByLink();
} else {
setState(prev => ({
...prev,
step: 'error',
error: "Invalid interview link"
}));
}
}, [linkId]);
const fetchJobByLink = async () => {
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/rest/jobs/interview/${linkId}`);
if (response.ok) {
const data = await response.json();
setState(prev => ({
...prev,
step: 'consent',
job: data.job
}));
} else {
setState(prev => ({
...prev,
step: 'error',
error: "Interview link not found or expired"
}));
}
} catch (err) {
setState(prev => ({
...prev,
step: 'error',
error: "Failed to load interview"
}));
}
};
const handleConsent = (consent: boolean) => {
if (consent) {
setState(prev => ({
...prev,
step: 'name_input',
consentGiven: true
}));
} else {
// Log failed attempt and show sad smiley
logFailedAttempt();
setState(prev => ({
...prev,
step: 'completed'
}));
}
};
const handleNameSubmit = (name: string) => {
setState(prev => ({
...prev,
step: 'mandatory_questions',
candidateName: name
}));
};
const handleMandatoryQuestionsComplete = (answers: string[]) => {
setState(prev => ({
...prev,
step: 'chat',
mandatoryAnswers: answers
}));
};
const handleInterviewComplete = () => {
setState(prev => ({
...prev,
step: 'completed'
}));
};
const logFailedAttempt = async () => {
try {
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/rest/jobs/interview/${linkId}/failed`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
} catch (error) {
console.error('Failed to log failed attempt:', error);
}
};
if (state.step === 'loading') {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading interview...</p>
</div>
</div>
);
}
if (state.step === 'error' || !state.job) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">Interview Not Available</h1>
<p className="text-gray-600">{state.error}</p>
</div>
</div>
);
}
if (state.step === 'consent') {
return (
<ConsentScreen
job={state.job}
onConsent={handleConsent}
/>
);
}
if (state.step === 'name_input') {
return (
<NameInputScreen
onNameSubmit={handleNameSubmit}
/>
);
}
if (state.step === 'mandatory_questions') {
return (
<MandatoryQuestionsScreen
job={state.job!}
candidateName={state.candidateName}
linkId={linkId!}
isTestMode={isTestMode}
onComplete={handleMandatoryQuestionsComplete}
/>
);
}
if (state.step === 'chat') {
return (
<ChatScreen
job={state.job}
candidateName={state.candidateName}
linkId={linkId!}
isTestMode={isTestMode}
mandatoryAnswers={state.mandatoryAnswers}
onComplete={handleInterviewComplete}
/>
);
}
if (state.step === 'completed') {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-6">
{state.consentGiven ? (
<>
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">Interview Completed</h1>
<p className="text-gray-600 mb-4">
Thank you for completing the interview. We'll review your responses and get back to you soon.
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800">
<strong>Position:</strong> {state.job.title}
</p>
<p className="text-sm text-blue-800">
<strong>Company:</strong> {state.job.location || "Remote"}
</p>
</div>
</>
) : (
<>
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-4xl">😢</span>
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">Interview Declined</h1>
<p className="text-gray-600 mb-4">
We understand you've chosen not to proceed with the interview. Thank you for your time.
</p>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<p className="text-sm text-gray-600">
<strong>Position:</strong> {state.job.title}
</p>
<p className="text-sm text-gray-600">
<strong>Company:</strong> {state.job.location || "Remote"}
</p>
</div>
</>
)}
</div>
</div>
);
}
return null;
}
export default function InterviewPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading interview...</p>
</div>
</div>
}>
<InterviewPageInner />
</Suspense>
);
}

View File

@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "next-themes";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Candivista App",
description: "A modern authentication system with Next.js and TypeScript",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-white dark:bg-gray-900 text-gray-900 dark:text-white`}
>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,262 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import axios from "axios";
import { useRouter } from "next/navigation";
const loginSchema = z.object({
email: z.string().email("Please enter a valid email address"),
password: z.string().min(1, "Password is required"),
});
const registerSchema = z.object({
first_name: z.string().min(2, "First name must be at least 2 characters"),
last_name: z.string().min(2, "Last name must be at least 2 characters"),
email: z.string().email("Please enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
company_name: z.string().optional(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type LoginForm = z.infer<typeof loginSchema>;
type RegisterForm = z.infer<typeof registerSchema>;
export default function LoginPage() {
const [isLogin, setIsLogin] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const loginForm = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
});
const registerForm = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
});
const onLogin = async (data: LoginForm) => {
setIsLoading(true);
setError("");
try {
const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/rest/auth/login`, data);
const { token, user } = response.data;
localStorage.setItem("token", token);
localStorage.setItem("user", JSON.stringify(user));
router.push("/dashboard");
} catch (err: any) {
setError(err.response?.data?.message || "Login failed");
} finally {
setIsLoading(false);
}
};
const onRegister = async (data: RegisterForm) => {
setIsLoading(true);
setError("");
try {
const { confirmPassword, ...registerData } = data;
const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/rest/auth/register`, registerData);
const { token, user } = response.data;
localStorage.setItem("token", token);
localStorage.setItem("user", JSON.stringify(user));
router.push("/dashboard");
} catch (err: any) {
setError(err.response?.data?.message || "Registration failed");
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="max-w-md w-full space-y-8">
<div className="bg-white rounded-2xl shadow-xl p-8">
<div className="text-center">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
{isLogin ? "Welcome back" : "Create account"}
</h2>
<p className="text-gray-600 mb-8">
{isLogin ? "Sign in to your account" : "Sign up for a new account"}
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{isLogin ? (
<form onSubmit={loginForm.handleSubmit(onLogin)} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email address
</label>
<input
{...loginForm.register("email")}
type="email"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Enter your email"
/>
{loginForm.formState.errors.email && (
<p className="mt-1 text-sm text-red-600">{loginForm.formState.errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<input
{...loginForm.register("password")}
type="password"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Enter your password"
/>
{loginForm.formState.errors.password && (
<p className="mt-1 text-sm text-red-600">{loginForm.formState.errors.password.message}</p>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
{isLoading ? "Signing in..." : "Sign in"}
</button>
</form>
) : (
<form onSubmit={registerForm.handleSubmit(onRegister)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700 mb-2">
First name
</label>
<input
{...registerForm.register("first_name")}
type="text"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Enter your first name"
/>
{registerForm.formState.errors.first_name && (
<p className="mt-1 text-sm text-red-600">{registerForm.formState.errors.first_name.message}</p>
)}
</div>
<div>
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700 mb-2">
Last name
</label>
<input
{...registerForm.register("last_name")}
type="text"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Enter your last name"
/>
{registerForm.formState.errors.last_name && (
<p className="mt-1 text-sm text-red-600">{registerForm.formState.errors.last_name.message}</p>
)}
</div>
</div>
<div>
<label htmlFor="company_name" className="block text-sm font-medium text-gray-700 mb-2">
Company name (optional)
</label>
<input
{...registerForm.register("company_name")}
type="text"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Enter your company name"
/>
{registerForm.formState.errors.company_name && (
<p className="mt-1 text-sm text-red-600">{registerForm.formState.errors.company_name.message}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email address
</label>
<input
{...registerForm.register("email")}
type="email"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Enter your email"
/>
{registerForm.formState.errors.email && (
<p className="mt-1 text-sm text-red-600">{registerForm.formState.errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<input
{...registerForm.register("password")}
type="password"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Create a password"
/>
{registerForm.formState.errors.password && (
<p className="mt-1 text-sm text-red-600">{registerForm.formState.errors.password.message}</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
Confirm password
</label>
<input
{...registerForm.register("confirmPassword")}
type="password"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all text-gray-900 placeholder-gray-500"
placeholder="Confirm your password"
/>
{registerForm.formState.errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{registerForm.formState.errors.confirmPassword.message}</p>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
{isLoading ? "Creating account..." : "Create account"}
</button>
</form>
)}
<div className="mt-6 text-center">
<button
onClick={() => {
setIsLogin(!isLogin);
setError("");
loginForm.reset();
registerForm.reset();
}}
className="text-blue-600 hover:text-blue-700 font-medium transition-colors"
>
{isLogin ? "Don't have an account? Sign up" : "Already have an account? Sign in"}
</button>
</div>
</div>
</div>
</div>
);
}

491
frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,491 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import AnimatedCounter from "@/components/AnimatedCounter";
import FeatureCard from "@/components/FeatureCard";
import PricingCard from "@/components/PricingCard";
import TechStackCard from "@/components/TechStackCard";
export default function Home() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [activeFeature, setActiveFeature] = useState(0);
useEffect(() => {
// Check if user is already logged in
const token = localStorage.getItem("token");
if (token) {
router.push("/dashboard");
} else {
setIsLoading(false);
}
}, [router]);
useEffect(() => {
const interval = setInterval(() => {
setActiveFeature((prev) => (prev + 1) % 4);
}, 3000);
return () => clearInterval(interval);
}, []);
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-blue-600 border-t-transparent mx-auto"></div>
<p className="mt-6 text-gray-600 text-lg">Loading Candivista...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50">
{/* Navigation */}
<nav className="bg-white/90 backdrop-blur-md border-b border-gray-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<div className="flex-shrink-0 flex items-center space-x-2">
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">C</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">Candivista</h1>
</div>
</div>
<div className="flex items-center space-x-4">
<button
onClick={() => router.push("/login")}
className="bg-gradient-to-r from-blue-600 to-indigo-600 text-white px-6 py-2 rounded-lg font-medium hover:from-blue-700 hover:to-indigo-700 transition-all duration-300 transform hover:scale-105 shadow-lg"
>
Get Started
</button>
</div>
</div>
</div>
</nav>
{/* Hero Section */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
<div className="text-center">
<div className="inline-flex items-center px-4 py-2 rounded-full bg-blue-100 text-blue-800 text-sm font-medium mb-8 animate-pulse">
🚀 AI-Powered Interview Platform
</div>
<h1 className="text-6xl md:text-7xl font-bold text-gray-900 mb-6 leading-tight">
The Future of{" "}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 animate-gradient-x">
AI Recruitment
</span>
</h1>
<p className="text-xl md:text-2xl text-gray-600 mb-12 max-w-4xl mx-auto leading-relaxed">
Transform your hiring process with our comprehensive AI-powered multi-tenant interview platform.
Create job listings, conduct intelligent interviews, and manage candidates with unprecedented flexibility.
</p>
<div className="flex flex-col sm:flex-row gap-6 justify-center items-center mb-16">
<button
onClick={() => router.push("/login")}
className="bg-gradient-to-r from-blue-600 to-indigo-600 text-white px-10 py-4 rounded-xl font-semibold text-lg hover:from-blue-700 hover:to-indigo-700 transition-all duration-300 transform hover:scale-105 shadow-2xl hover:shadow-blue-500/25"
>
Start Free Trial
</button>
<button
onClick={() => document.getElementById('features')?.scrollIntoView({ behavior: 'smooth' })}
className="border-2 border-gray-300 text-gray-700 px-10 py-4 rounded-xl font-semibold text-lg hover:border-blue-600 hover:text-blue-600 transition-all duration-300"
>
Learn More
</button>
</div>
{/* Hero Illustration */}
<div className="relative max-w-4xl mx-auto mb-20">
<div className="bg-gradient-to-r from-blue-500/10 to-purple-500/10 rounded-3xl p-8 backdrop-blur-sm border border-white/20">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-center">
<div className="text-center">
<div className="w-20 h-20 bg-gradient-to-r from-blue-500 to-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-bounce">
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2-2v2m8 0V6a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2V6" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Create Jobs</h3>
<p className="text-gray-600 text-sm">Design compelling job postings with AI assistance</p>
</div>
<div className="text-center">
<div className="w-20 h-20 bg-gradient-to-r from-purple-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-bounce" style={{ animationDelay: '0.5s' }}>
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">AI Interviews</h3>
<p className="text-gray-600 text-sm">Conduct intelligent interviews with automated scoring</p>
</div>
<div className="text-center">
<div className="w-20 h-20 bg-gradient-to-r from-indigo-500 to-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-bounce" style={{ animationDelay: '1s' }}>
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Analytics</h3>
<p className="text-gray-600 text-sm">Get insights and track candidate performance</p>
</div>
</div>
</div>
</div>
</div>
{/* Features Section */}
<div id="features" className="mt-32">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
Powerful Features for Modern Recruitment
</h2>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
Everything you need to streamline your hiring process and find the best talent
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center mb-20">
<div className="space-y-8">
<FeatureCard
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
}
title="Multi-Tenant Architecture"
description="Complete data isolation between companies with enterprise-grade security. Scale to thousands of tenants with confidence."
gradient="bg-gradient-to-r from-blue-500 to-blue-600"
delay={0}
/>
<FeatureCard
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
}
title="Flexible Link System"
description="Revolutionary token-based interview distribution. Create custom links with flexible application limits for maximum control."
gradient="bg-gradient-to-r from-purple-500 to-purple-600"
delay={200}
/>
<FeatureCard
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
}
title="AI-Powered Intelligence"
description="Local Ollama integration with gpt-oss:20b model for privacy-focused AI interviews. Automated question generation and real-time scoring."
gradient="bg-gradient-to-r from-green-500 to-green-600"
delay={400}
/>
</div>
<div className="relative">
<div className="bg-gradient-to-br from-blue-500/10 to-purple-500/10 rounded-3xl p-8 backdrop-blur-sm border border-white/20">
<div className="space-y-6">
<div className="bg-white rounded-2xl p-6 shadow-lg">
<div className="flex items-center space-x-3 mb-4">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
<h4 className="font-semibold text-gray-800 mb-2">Interview Dashboard</h4>
<div className="space-y-3">
<div className="h-2 bg-gray-200 rounded-full">
<div className="h-2 bg-blue-500 rounded-full w-3/4"></div>
</div>
<div className="h-2 bg-gray-200 rounded-full">
<div className="h-2 bg-green-500 rounded-full w-1/2"></div>
</div>
<div className="h-2 bg-gray-200 rounded-full">
<div className="h-2 bg-purple-500 rounded-full w-5/6"></div>
</div>
</div>
</div>
<div className="bg-white rounded-2xl p-6 shadow-lg">
<h4 className="font-semibold text-gray-800 mb-4">AI Analysis</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Technical Skills</span>
<span className="font-semibold text-green-600">85%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Communication</span>
<span className="font-semibold text-blue-600">92%</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Problem Solving</span>
<span className="font-semibold text-purple-600">78%</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Pricing Section */}
<div className="mt-32 bg-gradient-to-r from-gray-900 to-gray-800 rounded-3xl p-12 text-white">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-bold mb-6">
Simple, Transparent Pricing
</h2>
<p className="text-xl text-gray-300 max-w-3xl mx-auto">
Pay only for what you use with our flexible token-based system
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<PricingCard
name="Single Token"
tokens={1}
price="$5.00"
description="Perfect for testing"
features={["1 Interview Token", "Basic Support", "Standard Features"]}
gradient="bg-gradient-to-r from-gray-500 to-gray-600"
/>
<PricingCard
name="Starter Pack"
tokens={5}
price="$22.50"
description="Small recruitment needs"
features={["5 Interview Tokens", "Email Support", "Basic Analytics", "10% Discount"]}
gradient="bg-gradient-to-r from-blue-500 to-blue-600"
/>
<PricingCard
name="Professional"
tokens={20}
price="$80.00"
description="Regular recruiters"
popular={true}
features={["20 Interview Tokens", "Priority Support", "Advanced Analytics", "20% Discount", "Custom Branding"]}
gradient="bg-gradient-to-r from-purple-500 to-purple-600"
/>
<PricingCard
name="Enterprise"
tokens={100}
price="$300.00"
description="Large teams"
features={["100 Interview Tokens", "Dedicated Support", "Full Analytics", "40% Discount", "White-label Solution", "API Access"]}
gradient="bg-gradient-to-r from-indigo-500 to-indigo-600"
/>
</div>
</div>
{/* Stats Section */}
<div className="mt-32 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-3xl p-12">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
Trusted by Companies Worldwide
</h2>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
Join thousands of companies already using Candivista to streamline their recruitment process
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="text-center">
<div className="text-5xl font-bold text-blue-600 mb-2">
<AnimatedCounter end={10000} suffix="+" />
</div>
<p className="text-gray-600 text-lg">Interviews Conducted</p>
</div>
<div className="text-center">
<div className="text-5xl font-bold text-purple-600 mb-2">
<AnimatedCounter end={500} suffix="+" />
</div>
<p className="text-gray-600 text-lg">Companies</p>
</div>
<div className="text-center">
<div className="text-5xl font-bold text-green-600 mb-2">
<AnimatedCounter end={50} suffix="+" />
</div>
<p className="text-gray-600 text-lg">Countries</p>
</div>
<div className="text-center">
<div className="text-5xl font-bold text-indigo-600 mb-2">
<AnimatedCounter end={99} suffix="%" />
</div>
<p className="text-gray-600 text-lg">Satisfaction Rate</p>
</div>
</div>
</div>
{/* Technology Stack */}
<div className="mt-32">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
Built with Modern Technology
</h2>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
Leveraging the latest technologies for optimal performance and developer experience
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
<TechStackCard
name="Next.js 15"
icon="⚡"
description="React Framework"
delay={0}
/>
<TechStackCard
name="TypeScript"
icon="🔷"
description="Type Safety"
delay={100}
/>
<TechStackCard
name="MySQL"
icon="🗄️"
description="Database"
delay={200}
/>
<TechStackCard
name="Docker"
icon="🐳"
description="Containerization"
delay={300}
/>
<TechStackCard
name="Ollama AI"
icon="🤖"
description="Local AI"
delay={400}
/>
<TechStackCard
name="Tailwind CSS"
icon="🎨"
description="Styling"
delay={500}
/>
<TechStackCard
name="Prisma ORM"
icon="🔧"
description="Database ORM"
delay={600}
/>
<TechStackCard
name="Cloudflare"
icon="☁️"
description="CDN & Security"
delay={700}
/>
</div>
</div>
{/* CTA Section */}
<div className="mt-32 bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 rounded-3xl p-12 text-center text-white relative overflow-hidden">
<div className="absolute inset-0 bg-black/10"></div>
<div className="relative z-10">
<h2 className="text-4xl md:text-5xl font-bold mb-6">
Ready to Transform Your Hiring?
</h2>
<p className="text-xl mb-8 opacity-90 max-w-3xl mx-auto">
Join thousands of companies already using Candivista to streamline their recruitment process
and find the best talent with AI-powered interviews.
</p>
<div className="flex flex-col sm:flex-row gap-6 justify-center items-center">
<button
onClick={() => router.push("/login")}
className="bg-white text-blue-600 px-10 py-4 rounded-xl font-semibold text-lg hover:bg-gray-100 transition-all duration-300 transform hover:scale-105 shadow-2xl"
>
Start Free Trial
</button>
<button
onClick={() => document.getElementById('features')?.scrollIntoView({ behavior: 'smooth' })}
className="border-2 border-white text-white px-10 py-4 rounded-xl font-semibold text-lg hover:bg-white hover:text-blue-600 transition-all duration-300"
>
Learn More
</button>
</div>
</div>
</div>
</main>
{/* Footer */}
<footer className="bg-gray-900 text-white py-16 mt-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="col-span-1 md:col-span-2">
<div className="flex items-center space-x-2 mb-6">
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">C</span>
</div>
<h3 className="text-2xl font-bold">Candivista</h3>
</div>
<p className="text-gray-400 mb-6 max-w-md">
The future of AI-powered recruitment. Transform your hiring process with intelligent interviews,
flexible link management, and comprehensive analytics.
</p>
<div className="flex space-x-4">
<a href="#" className="text-gray-400 hover:text-white transition-colors">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"/>
</svg>
</a>
<a href="#" className="text-gray-400 hover:text-white transition-colors">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M22.46 6c-.77.35-1.6.58-2.46.69.88-.53 1.56-1.37 1.88-2.38-.83.5-1.75.85-2.72 1.05C18.37 4.5 17.26 4 16 4c-2.35 0-4.27 1.92-4.27 4.29 0 .34.04.67.11.98C8.28 9.09 5.11 7.38 3 4.79c-.37.63-.58 1.37-.58 2.15 0 1.49.75 2.81 1.91 3.56-.71 0-1.37-.2-1.95-.5v.03c0 2.08 1.48 3.82 3.44 4.21a4.22 4.22 0 0 1-1.93.07 4.28 4.28 0 0 0 4 2.98 8.521 8.521 0 0 1-5.33 1.84c-.34 0-.68-.02-1.02-.06C3.44 20.29 5.7 21 8.12 21 16 21 20.33 14.46 20.33 8.79c0-.19 0-.37-.01-.56.84-.6 1.56-1.36 2.14-2.23z"/>
</svg>
</a>
<a href="#" className="text-gray-400 hover:text-white transition-colors">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</a>
</div>
</div>
<div>
<h4 className="text-lg font-semibold mb-4">Product</h4>
<ul className="space-y-2">
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">Features</a></li>
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">Pricing</a></li>
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">API</a></li>
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">Documentation</a></li>
</ul>
</div>
<div>
<h4 className="text-lg font-semibold mb-4">Company</h4>
<ul className="space-y-2">
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">About</a></li>
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">Blog</a></li>
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">Careers</a></li>
<li><a href="#" className="text-gray-400 hover:text-white transition-colors">Contact</a></li>
</ul>
</div>
</div>
<div className="mt-12 pt-8 border-t border-gray-800">
<div className="flex flex-col md:flex-row justify-between items-center">
<p className="text-gray-400 text-sm">
© 2024 Candivista. All rights reserved.
</p>
<div className="flex space-x-6 mt-4 md:mt-0">
<a href="#" className="text-gray-400 hover:text-white transition-colors text-sm">Privacy Policy</a>
<a href="#" className="text-gray-400 hover:text-white transition-colors text-sm">Terms of Service</a>
<a href="#" className="text-gray-400 hover:text-white transition-colors text-sm">Cookie Policy</a>
</div>
</div>
</div>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,240 @@
"use client";
import { useState } from "react";
interface SystemStatistics {
total_users: number;
active_users: number;
total_jobs: number;
total_interviews: number;
total_tokens_purchased: number;
total_tokens_used: number;
total_revenue: number;
generated_at: string;
}
interface AdminDashboardProps {
stats: SystemStatistics | null;
onRefresh: () => void;
}
export default function AdminDashboard({ stats, onRefresh }: AdminDashboardProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
setIsRefreshing(true);
await onRefresh();
setIsRefreshing(false);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
const formatNumber = (num: number) => {
return new Intl.NumberFormat('en-US').format(num);
};
const getTokenUtilization = () => {
if (!stats || stats.total_tokens_purchased === 0) return 0;
return Math.round((stats.total_tokens_used / stats.total_tokens_purchased) * 100);
};
const getActiveUserPercentage = () => {
if (!stats || stats.total_users === 0) return 0;
return Math.round((stats.active_users / stats.total_users) * 100);
};
const recentActivities = [
{
id: 1,
type: "user_registration",
message: "New user registered: john.doe@company.com",
timestamp: "2 minutes ago",
icon: "👤"
},
{
id: 2,
type: "job_created",
message: "New job posted: Senior Frontend Developer",
timestamp: "15 minutes ago",
icon: "📢"
},
{
id: 3,
type: "token_purchase",
message: "Token package purchased: Professional Pack (20 tokens)",
timestamp: "1 hour ago",
icon: "🪙"
},
{
id: 4,
type: "interview_completed",
message: "Interview completed for Software Engineer position",
timestamp: "2 hours ago",
icon: "✅"
}
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white">System Overview</h2>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Monitor system performance and user activity
</p>
</div>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
>
<svg className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{isRefreshing ? 'Refreshing...' : 'Refresh'}</span>
</button>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Total Users */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total Users</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats ? formatNumber(stats.total_users) : '--'}
</p>
</div>
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
<span className="text-2xl">👥</span>
</div>
</div>
<div className="mt-4 flex items-center text-sm">
<span className="text-green-600 dark:text-green-400 font-medium">
{stats ? getActiveUserPercentage() : 0}% active
</span>
<span className="text-gray-500 dark:text-gray-400 ml-2">
({stats ? formatNumber(stats.active_users) : 0} active)
</span>
</div>
</div>
{/* Total Jobs */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total Jobs</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats ? formatNumber(stats.total_jobs) : '--'}
</p>
</div>
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center">
<span className="text-2xl">📢</span>
</div>
</div>
<div className="mt-4 text-sm text-gray-500 dark:text-gray-400">
Job postings created
</div>
</div>
{/* Token Utilization */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Token Utilization</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats ? getTokenUtilization() : 0}%
</p>
</div>
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900/20 rounded-lg flex items-center justify-center">
<span className="text-2xl">🪙</span>
</div>
</div>
<div className="mt-4 text-sm text-gray-500 dark:text-gray-400">
{stats ? `${formatNumber(stats.total_tokens_used)} / ${formatNumber(stats.total_tokens_purchased)} used` : 'No data'}
</div>
</div>
{/* Total Revenue */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total Revenue</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats ? formatCurrency(stats.total_revenue) : '$0'}
</p>
</div>
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-lg flex items-center justify-center">
<span className="text-2xl">💰</span>
</div>
</div>
<div className="mt-4 text-sm text-gray-500 dark:text-gray-400">
From token sales
</div>
</div>
</div>
{/* Charts and Recent Activity */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Activity */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Recent Activity</h3>
<div className="space-y-4">
{recentActivities.map((activity) => (
<div key={activity.id} className="flex items-start space-x-3">
<div className="w-8 h-8 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
<span className="text-sm">{activity.icon}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900 dark:text-white">{activity.message}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{activity.timestamp}</p>
</div>
</div>
))}
</div>
</div>
{/* Quick Actions */}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Quick Actions</h3>
<div className="space-y-3">
<button className="w-full text-left p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div className="flex items-center space-x-3">
<span className="text-lg">👤</span>
<div>
<div className="font-medium text-gray-900 dark:text-white">Add New User</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Create a new user account</div>
</div>
</div>
</button>
<button className="w-full text-left p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div className="flex items-center space-x-3">
<span className="text-lg">🪙</span>
<div>
<div className="font-medium text-gray-900 dark:text-white">Add Tokens</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Grant tokens to a user</div>
</div>
</div>
</button>
<button className="w-full text-left p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div className="flex items-center space-x-3">
<span className="text-lg">📊</span>
<div>
<div className="font-medium text-gray-900 dark:text-white">View Reports</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Generate system reports</div>
</div>
</div>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
interface User {
first_name: string;
last_name: string;
email: string;
role: string;
}
interface AdminHeaderProps {
user?: User;
onLogout?: () => void;
}
export default function AdminHeader({ user, onLogout }: AdminHeaderProps) {
const [isProfileOpen, setIsProfileOpen] = useState(false);
return (
<header className="bg-white dark:bg-gray-900 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div className="px-6 py-4">
<div className="flex items-center justify-between">
{/* Page Title */}
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Admin Dashboard
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage users, jobs, and system settings
</p>
</div>
{/* User Menu */}
<div className="flex items-center space-x-4">
{/* Notifications */}
<button className="relative p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-5 5v-5zM4 19h6v-6H4v6zM4 5h6V1H4v4zM15 7h5l-5-5v5z" />
</svg>
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full"></span>
</button>
{/* Profile Dropdown */}
<div className="relative">
<button
onClick={() => setIsProfileOpen(!isProfileOpen)}
className="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<div className="w-8 h-8 bg-red-600 rounded-full flex items-center justify-center">
<span className="text-white font-medium text-sm">
{user?.first_name?.[0]}{user?.last_name?.[0]}
</span>
</div>
<div className="text-left">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{user?.first_name} {user?.last_name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{user?.role}
</div>
</div>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown Menu */}
{isProfileOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="py-1">
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{user?.first_name} {user?.last_name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{user?.email}
</div>
</div>
<button className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
Profile Settings
</button>
<button className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
System Settings
</button>
<div className="border-t border-gray-200 dark:border-gray-700"></div>
<button
onClick={onLogout}
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
Sign Out
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,46 @@
"use client";
import { ReactNode } from "react";
import AdminSidebar from "./AdminSidebar";
import AdminHeader from "./AdminHeader";
interface User {
first_name: string;
last_name: string;
email: string;
role: string;
}
interface AdminLayoutProps {
children: ReactNode;
user?: User;
activeTab?: string;
onTabChange?: (tab: string) => void;
onLogout?: () => void;
}
export default function AdminLayout({
children,
user,
activeTab = "dashboard",
onTabChange,
onLogout
}: AdminLayoutProps) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex">
<AdminSidebar
activeTab={activeTab}
onTabChange={onTabChange}
/>
<div className="flex-1 flex flex-col">
<AdminHeader
user={user}
onLogout={onLogout}
/>
<main className="flex-1 p-6 bg-gray-50 dark:bg-gray-900">
{children}
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,101 @@
"use client";
interface AdminSidebarProps {
activeTab?: string;
onTabChange?: (tab: string) => void;
}
export default function AdminSidebar({ activeTab = "dashboard", onTabChange }: AdminSidebarProps) {
const menuItems = [
{
id: "dashboard",
label: "Dashboard",
icon: "📊",
description: "Overview and analytics"
},
{
id: "users",
label: "User Management",
icon: "👥",
description: "Manage users and permissions"
},
{
id: "jobs",
label: "Job Management",
icon: "📢",
description: "View and manage all jobs"
},
{
id: "tokens",
label: "Token Management",
icon: "🪙",
description: "Manage interview tokens"
},
{
id: "stats",
label: "System Statistics",
icon: "📈",
description: "Detailed system metrics"
},
{
id: "devtools",
label: "Developer Tools",
icon: "🛠️",
description: "Swagger, Portainer, Docs"
}
];
return (
<div className="w-64 bg-white dark:bg-gray-900 shadow-sm border-r border-gray-200 dark:border-gray-700 flex flex-col">
{/* Logo */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center">
<div className="w-8 h-8 bg-red-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">A</span>
</div>
<div className="ml-3">
<span className="text-xl font-bold text-gray-900 dark:text-white">Admin Panel</span>
<p className="text-xs text-gray-500 dark:text-gray-400">Candivista</p>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-2">
{menuItems.map((item) => (
<button
key={item.id}
onClick={() => onTabChange?.(item.id)}
className={`w-full flex items-start px-3 py-3 text-sm font-medium rounded-lg transition-colors group ${
activeTab === item.id
? "text-white bg-red-600 dark:bg-red-700"
: "text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
}`}
>
<span className="text-lg mr-3 mt-0.5">{item.icon}</span>
<div className="text-left">
<div className="font-medium">{item.label}</div>
<div className={`text-xs mt-0.5 ${
activeTab === item.id
? "text-red-100"
: "text-gray-500 dark:text-gray-400"
}`}>
{item.description}
</div>
</div>
</button>
))}
</nav>
{/* Admin Badge */}
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center px-3 py-2 bg-red-50 dark:bg-red-900/20 rounded-lg">
<div className="w-2 h-2 bg-red-500 rounded-full mr-2"></div>
<span className="text-xs font-medium text-red-700 dark:text-red-300">
Admin Access
</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
"use client";
import { useEffect, useState } from "react";
interface AnimatedCounterProps {
end: number;
duration?: number;
prefix?: string;
suffix?: string;
className?: string;
}
export default function AnimatedCounter({
end,
duration = 2000,
prefix = "",
suffix = "",
className = ""
}: AnimatedCounterProps) {
const [count, setCount] = useState(0);
useEffect(() => {
let startTime: number;
let animationFrame: number;
const animate = (currentTime: number) => {
if (!startTime) startTime = currentTime;
const progress = Math.min((currentTime - startTime) / duration, 1);
// Easing function for smooth animation
const easeOutCubic = 1 - Math.pow(1 - progress, 3);
const currentCount = Math.floor(easeOutCubic * end);
setCount(currentCount);
if (progress < 1) {
animationFrame = requestAnimationFrame(animate);
}
};
animationFrame = requestAnimationFrame(animate);
return () => {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
};
}, [end, duration]);
return (
<span className={className}>
{prefix}{count.toLocaleString()}{suffix}
</span>
);
}

Some files were not shown because too many files have changed in this diff Show More