Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a868d7f14 | |||
| 824bf93dfb | |||
| 7f9cf79a21 | |||
| b83c448573 | |||
| ec8342b5e2 |
46
AISApp/Dockerfile
Normal file
46
AISApp/Dockerfile
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# Multi-stage build for ASP.NET Core application
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy project files
|
||||||
|
COPY AISApp.csproj .
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Restore dependencies
|
||||||
|
RUN dotnet restore AISApp.csproj
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN dotnet build AISApp.csproj -c Release -o /app/build
|
||||||
|
|
||||||
|
# Publish the application
|
||||||
|
RUN dotnet publish AISApp.csproj -c Release -o /app/publish
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl for health checks
|
||||||
|
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy published application
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
# Copy prompt.txt file
|
||||||
|
COPY prompt.txt .
|
||||||
|
|
||||||
|
# Create directory for static files
|
||||||
|
RUN mkdir -p static
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV ASPNETCORE_URLS=http://+:80
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD curl -f http://localhost/api/chat || exit 1
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
ENTRYPOINT ["dotnet", "AISApp.dll"]
|
||||||
@ -20,3 +20,5 @@ This will start the application with all necessary services.
|
|||||||
---
|
---
|
||||||
|
|
||||||
This README provides the basic steps to configure and run the AISApp project using Docker Compose and environment variables.
|
This README provides the basic steps to configure and run the AISApp project using Docker Compose and environment variables.
|
||||||
|
|
||||||
|
portainer= tunaadmin/tunatainer8!
|
||||||
153
TODO.md
Normal file
153
TODO.md
Normal 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**: `AISApp/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**: `AISApp/AISApp/AIS.cs`, `AISApp/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**: `AISApp/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**: `AISApp/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**: `AISApp/AISApp/prompt.txt`, `AISApp/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
9
backend/.barrels.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"directory": ["./src/controllers/rest","./src/controllers/pages"],
|
||||||
|
"exclude": [
|
||||||
|
"**/__mock__",
|
||||||
|
"**/__mocks__",
|
||||||
|
"**/*.spec.ts"
|
||||||
|
],
|
||||||
|
"delete": true
|
||||||
|
}
|
||||||
14
backend/.dockerignore
Normal file
14
backend/.dockerignore
Normal 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
61
backend/.gitignore
vendored
Normal 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
21
backend/.swcrc
Normal 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
306
backend/ADMIN_API.md
Normal 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
|
||||||
52
backend/AI_CONFIGURATION.md
Normal file
52
backend/AI_CONFIGURATION.md
Normal 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
30
backend/Dockerfile
Normal 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
67
backend/README.md
Normal 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> • </span>
|
||||||
|
<a href="https://cli.tsed.dev/getting-started.html">Getting started</a>
|
||||||
|
<span> • </span>
|
||||||
|
<a href="https://slack.tsed.dev">Slack</a>
|
||||||
|
<span> • </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
|
||||||
|
}
|
||||||
|
```
|
||||||
3
backend/add-icon-column.sql
Normal file
3
backend/add-icon-column.sql
Normal 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;
|
||||||
7
backend/add-interview-style-column.sql
Normal file
7
backend/add-interview-style-column.sql
Normal 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;
|
||||||
7
backend/add-tokens-used-column.sql
Normal file
7
backend/add-tokens-used-column.sql
Normal 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;
|
||||||
13
backend/create-job-links-table.sql
Normal file
13
backend/create-job-links-table.sql
Normal 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)
|
||||||
|
);
|
||||||
46
backend/docker-compose.yml
Normal file
46
backend/docker-compose.yml
Normal 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: ../../AISApp
|
||||||
|
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
9
backend/nodemon.json
Normal 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
5573
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
77
backend/package.json
Normal file
77
backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
22
backend/processes.config.cjs
Normal file
22
backend/processes.config.cjs
Normal 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
2
backend/secret.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
Portainer: 168.231.108.135:9443 / tundaadmin / retoortunapass1
|
||||||
|
|
||||||
106
backend/src/Server.ts
Normal file
106
backend/src/Server.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
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-powered interviews (OpenRouter/Ollama), and admin reporting.\n\n" +
|
||||||
|
"AI Features:\n" +
|
||||||
|
"- OpenRouter integration for cloud-based AI interviews\n" +
|
||||||
|
"- Ollama support for local AI processing\n" +
|
||||||
|
"- Test mode for admin interview testing\n" +
|
||||||
|
"- Mandatory question support before AI interviews",
|
||||||
|
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-powered interview operations with OpenRouter and Ollama support" }
|
||||||
|
],
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
54
backend/src/config/database.ts
Normal file
54
backend/src/config/database.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/src/config/envs/index.ts
Normal file
7
backend/src/config/envs/index.ts
Normal 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
|
||||||
14
backend/src/config/index.ts
Normal file
14
backend/src/config/index.ts
Normal 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
|
||||||
|
};
|
||||||
25
backend/src/config/logger/index.ts
Normal file
25
backend/src/config/logger/index.ts
Normal 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
|
||||||
|
};
|
||||||
29
backend/src/controllers/pages/IndexController.ts
Normal file
29
backend/src/controllers/pages/IndexController.ts
Normal 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
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
4
backend/src/controllers/pages/index.ts
Normal file
4
backend/src/controllers/pages/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* @file Automatically generated by @tsed/barrels.
|
||||||
|
*/
|
||||||
|
export * from "./IndexController.js";
|
||||||
677
backend/src/controllers/rest/AIController.ts
Normal file
677
backend/src/controllers/rest/AIController.ts
Normal file
@ -0,0 +1,677 @@
|
|||||||
|
import { Controller } from "@tsed/di";
|
||||||
|
import { Post, Get, Tags, Summary, Description, Returns, Security } 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")
|
||||||
|
@Tags("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")
|
||||||
|
@Summary("Test AI connection")
|
||||||
|
@Description("Test the AI service connection and configuration. Works with both Ollama and OpenRouter providers.")
|
||||||
|
@(Returns(200, Object).Description("AI test result with success status and response"))
|
||||||
|
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")
|
||||||
|
@Summary("Get mandatory interview questions")
|
||||||
|
@Description("Retrieve mandatory questions for a specific job interview link")
|
||||||
|
@(Returns(200, Object).Description("List of mandatory questions for the job"))
|
||||||
|
@(Returns(404, Object).Description("Interview link not found or expired"))
|
||||||
|
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")
|
||||||
|
@Summary("Submit mandatory question answers")
|
||||||
|
@Description("Submit answers to mandatory interview questions before starting the AI interview")
|
||||||
|
@(Returns(200, Object).Description("Success response with interview data"))
|
||||||
|
@(Returns(400, Object).Description("Missing required fields"))
|
||||||
|
@(Returns(404, Object).Description("Interview link not found or expired"))
|
||||||
|
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")
|
||||||
|
@Summary("Start AI interview")
|
||||||
|
@Description("Initialize an AI-powered interview session. Can be used in test mode for admins.")
|
||||||
|
@(Returns(200, Object).Description("Interview started successfully with initial AI message"))
|
||||||
|
@(Returns(400, Object).Description("Missing required fields"))
|
||||||
|
@(Returns(404, Object).Description("Interview link not found or expired"))
|
||||||
|
@(Returns(500, Object).Description("AI service unavailable"))
|
||||||
|
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")
|
||||||
|
@Summary("Send chat message to AI")
|
||||||
|
@Description("Send a message to the AI interviewer and receive a response. Supports both test and production modes.")
|
||||||
|
@(Returns(200, Object).Description("AI response message"))
|
||||||
|
@(Returns(400, Object).Description("Missing required fields"))
|
||||||
|
@(Returns(404, Object).Description("Interview link not found or expired"))
|
||||||
|
@(Returns(500, Object).Description("AI service unavailable"))
|
||||||
|
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: any) =>
|
||||||
|
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")
|
||||||
|
@Summary("Get conversation history")
|
||||||
|
@Description("Retrieve the conversation history for a specific interview")
|
||||||
|
@(Returns(200, Object).Description("Conversation history messages"))
|
||||||
|
@(Returns(404, Object).Description("Interview link not found or expired"))
|
||||||
|
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")
|
||||||
|
@Summary("End interview session")
|
||||||
|
@Description("End an active interview session and mark it as completed")
|
||||||
|
@(Returns(200, Object).Description("Interview ended successfully"))
|
||||||
|
@(Returns(404, Object).Description("Interview link or interview not found"))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
266
backend/src/controllers/rest/AdminController.ts
Normal file
266
backend/src/controllers/rest/AdminController.ts
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job Links Management
|
||||||
|
@Get("/jobs/:id/links")
|
||||||
|
@Summary("Get job links")
|
||||||
|
@(Returns(200).Description("Job links returned"))
|
||||||
|
async getJobLinks(@Req() req: any, @PathParams("id") id: string) {
|
||||||
|
await this.checkAdmin(req);
|
||||||
|
return await this.adminService.getJobLinks(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/jobs/:id/create-link")
|
||||||
|
@Summary("Create job link for testing")
|
||||||
|
@(Returns(200).Description("Job link created"))
|
||||||
|
async createJobLink(@Req() req: any, @PathParams("id") id: string, @BodyParams() linkData: any) {
|
||||||
|
await this.checkAdmin(req);
|
||||||
|
return await this.adminService.createJobLink(id, linkData.tokensAvailable || 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
backend/src/controllers/rest/AuthController.ts
Normal file
167
backend/src/controllers/rest/AuthController.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/controllers/rest/HelloWorldController.ts
Normal file
10
backend/src/controllers/rest/HelloWorldController.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
501
backend/src/controllers/rest/JobController.ts
Normal file
501
backend/src/controllers/rest/JobController.ts
Normal 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
backend/src/controllers/rest/UserController.ts
Normal file
75
backend/src/controllers/rest/UserController.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/controllers/rest/index.ts
Normal file
9
backend/src/controllers/rest/index.ts
Normal 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
36
backend/src/index.ts
Normal 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});
|
||||||
|
}
|
||||||
|
|
||||||
51
backend/src/middleware/adminAuth.ts
Normal file
51
backend/src/middleware/adminAuth.ts
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
66
backend/src/models/User.ts
Normal file
66
backend/src/models/User.ts
Normal 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;
|
||||||
|
}
|
||||||
294
backend/src/services/AIService.ts
Normal file
294
backend/src/services/AIService.ts
Normal 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.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
830
backend/src/services/AdminService.ts
Normal file
830
backend/src/services/AdminService.ts
Normal file
@ -0,0 +1,830 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job Links Management
|
||||||
|
async getJobLinks(jobId: string) {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rows] = await connection.execute(
|
||||||
|
'SELECT * FROM job_links WHERE job_id = ? ORDER BY created_at DESC',
|
||||||
|
[jobId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(rows)) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
$log.error('Error getting job links:', error);
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createJobLink(jobId: string, tokensAvailable: number = 1) {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate a random URL slug and UUID
|
||||||
|
const linkId = randomUUID();
|
||||||
|
const urlSlug = randomUUID().replace(/-/g, '').substring(0, 8);
|
||||||
|
|
||||||
|
await connection.execute(
|
||||||
|
'INSERT INTO job_links (id, job_id, url_slug, tokens_available, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
|
||||||
|
[linkId, jobId, urlSlug, tokensAvailable]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the created link
|
||||||
|
const [rows] = await connection.execute(
|
||||||
|
'SELECT * FROM job_links WHERE id = ?',
|
||||||
|
[linkId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(rows) && rows.length > 0) {
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Failed to create job link');
|
||||||
|
} catch (error) {
|
||||||
|
$log.error('Error creating job link:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
170
backend/src/services/ChatbotService.ts
Normal file
170
backend/src/services/ChatbotService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1070
backend/src/services/JobService.ts
Normal file
1070
backend/src/services/JobService.ts
Normal file
File diff suppressed because it is too large
Load Diff
443
backend/src/services/TokenService.ts
Normal file
443
backend/src/services/TokenService.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
223
backend/src/services/UserService.ts
Normal file
223
backend/src/services/UserService.ts
Normal 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
171
backend/src/types/admin.ts
Normal 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
51
backend/src/types/auth.ts
Normal 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
|
||||||
|
};
|
||||||
29
backend/tsconfig.base.json
Normal file
29
backend/tsconfig.base.json
Normal 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
13
backend/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
20
backend/tsconfig.node.json
Normal file
20
backend/tsconfig.node.json
Normal 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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
1
backend/update-admin-password.sql
Normal file
1
backend/update-admin-password.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
95
backend/views/index.ejs
Normal file
95
backend/views/index.ejs
Normal 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
100
backend/views/swagger.ejs
Normal 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>
|
||||||
24
candidat-integrated.sln
Normal file
24
candidat-integrated.sln
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.5.2.0
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AISApp", "AISApp\AISApp.csproj", "{1671248C-43AD-2D25-4F0F-918991BEF94A}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{1671248C-43AD-2D25-4F0F-918991BEF94A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{1671248C-43AD-2D25-4F0F-918991BEF94A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{1671248C-43AD-2D25-4F0F-918991BEF94A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{1671248C-43AD-2D25-4F0F-918991BEF94A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {14FC4F28-B89B-49C6-BD21-D4B1F80AE49C}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
15
database/Dockerfile
Normal file
15
database/Dockerfile
Normal 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
|
||||||
55
database/candidb_dump1/candidb_main_audit_logs.sql
Normal file
55
database/candidb_dump1/candidb_main_audit_logs.sql
Normal 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
|
||||||
53
database/candidb_dump1/candidb_main_candidate_responses.sql
Normal file
53
database/candidb_dump1/candidb_main_candidate_responses.sql
Normal 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
|
||||||
63
database/candidb_dump1/candidb_main_candidates.sql
Normal file
63
database/candidb_dump1/candidb_main_candidates.sql
Normal 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
|
||||||
@ -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
|
||||||
52
database/candidb_dump1/candidb_main_interview_events.sql
Normal file
52
database/candidb_dump1/candidb_main_interview_events.sql
Normal 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
|
||||||
52
database/candidb_dump1/candidb_main_interview_questions.sql
Normal file
52
database/candidb_dump1/candidb_main_interview_questions.sql
Normal 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
|
||||||
79
database/candidb_dump1/candidb_main_interview_tokens.sql
Normal file
79
database/candidb_dump1/candidb_main_interview_tokens.sql
Normal 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
|
||||||
96
database/candidb_dump1/candidb_main_interviews.sql
Normal file
96
database/candidb_dump1/candidb_main_interviews.sql
Normal 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
|
||||||
52
database/candidb_dump1/candidb_main_job_links.sql
Normal file
52
database/candidb_dump1/candidb_main_job_links.sql
Normal 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
|
||||||
85
database/candidb_dump1/candidb_main_jobs.sql
Normal file
85
database/candidb_dump1/candidb_main_jobs.sql
Normal 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
|
||||||
59
database/candidb_dump1/candidb_main_payment_records.sql
Normal file
59
database/candidb_dump1/candidb_main_payment_records.sql
Normal 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
|
||||||
631
database/candidb_dump1/candidb_main_routines.sql
Normal file
631
database/candidb_dump1/candidb_main_routines.sql
Normal 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
|
||||||
55
database/candidb_dump1/candidb_main_token_packages.sql
Normal file
55
database/candidb_dump1/candidb_main_token_packages.sql
Normal 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
|
||||||
53
database/candidb_dump1/candidb_main_user_usage.sql
Normal file
53
database/candidb_dump1/candidb_main_user_usage.sql
Normal 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
|
||||||
60
database/candidb_dump1/candidb_main_users.sql
Normal file
60
database/candidb_dump1/candidb_main_users.sql
Normal 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
703
database/deploy_dump.sql
Normal 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());
|
||||||
|
|
||||||
|
|
||||||
@ -1,13 +1,191 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
aisapp2:
|
# Database Service
|
||||||
build: .
|
database:
|
||||||
ports:
|
build:
|
||||||
- "5000:80"
|
context: ./database
|
||||||
working_dir: /app
|
dockerfile: Dockerfile
|
||||||
command: ["dotnet", "run", "--project", "AISApp", "--urls", "http://0.0.0.0:80"]
|
image: candidat/database:${APP_VERSION:-latest}
|
||||||
volumes:
|
container_name: candidat-database
|
||||||
- .:/app
|
|
||||||
environment:
|
environment:
|
||||||
- ASPNETCORE_ENVIRONMENT=Development
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
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
|
||||||
|
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/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: ./AISApp
|
||||||
|
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
38
env.cloudflare
Normal 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
38
env.example
Normal 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
38
env.production
Normal 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
13
frontend/.dockerignore
Normal 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
41
frontend/.gitignore
vendored
Normal 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
55
frontend/Dockerfile
Normal 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
258
frontend/FRONTEND_README.md
Normal 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/
|
||||||
|
├── 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
36
frontend/README.md
Normal 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
24
frontend/next.config.js
Normal 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
7
frontend/next.config.ts
Normal 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
3761
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/postcss.config.mjs
Normal file
5
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal 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 |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal 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
1
frontend/public/next.svg
Normal 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 |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal 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 |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal 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 |
145
frontend/src/app/admin/page.tsx
Normal file
145
frontend/src/app/admin/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
frontend/src/app/dashboard/page.tsx
Normal file
204
frontend/src/app/dashboard/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
frontend/src/app/docs/page.tsx
Normal file
101
frontend/src/app/docs/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
236
frontend/src/app/globals.css
Normal file
236
frontend/src/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
275
frontend/src/app/interview/page.tsx
Normal file
275
frontend/src/app/interview/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/src/app/layout.tsx
Normal file
37
frontend/src/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
frontend/src/app/login/page.tsx
Normal file
262
frontend/src/app/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
512
frontend/src/app/page.tsx
Normal file
512
frontend/src/app/page.tsx
Normal file
@ -0,0 +1,512 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Image from "next/image";
|
||||||
|
import axios from "axios";
|
||||||
|
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) {
|
||||||
|
// Check if user is admin - if so, allow them to stay on landing page
|
||||||
|
// Non-admin users will be redirected to dashboard
|
||||||
|
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");
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// If token is invalid, remove it and stay on landing page
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
frontend/src/components/AdminDashboard.tsx
Normal file
240
frontend/src/components/AdminDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
frontend/src/components/AdminHeader.tsx
Normal file
102
frontend/src/components/AdminHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user