Integrate complete Candidat platform with ASP.NET chatbot service
- Added Next.js frontend for candidate interviews - Added Node.js backend with TypeScript and AI integration - Added ASP.NET Core chatbot service for specialized AI conversations - Added MySQL database with complete schema - Added Nginx reverse proxy configuration - Complete Docker Compose orchestration for all services - Environment configuration for production, development, and Cloudflare - Comprehensive documentation and setup instructions - Flattened nested folder structures for clean organization - Integrated chatbot service with fallback to direct AI calls
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"directory": ["./src/controllers/rest","./src/controllers/pages"],
|
||||
"exclude": [
|
||||
"**/__mock__",
|
||||
"**/__mocks__",
|
||||
"**/*.spec.ts"
|
||||
],
|
||||
"delete": true
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
version: '3.5'
|
||||
services:
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
args:
|
||||
- http_proxy
|
||||
- https_proxy
|
||||
- no_proxy
|
||||
image: backend/server:latest
|
||||
ports:
|
||||
- "8081:8081"
|
||||
environment:
|
||||
- CHATBOT_SERVICE_URL=http://chatbot:80
|
||||
- CHATBOT_SERVICE_TIMEOUT=30000
|
||||
- CHATBOT_FALLBACK_ENABLED=true
|
||||
depends_on:
|
||||
- chatbot
|
||||
networks:
|
||||
- candidat-network
|
||||
|
||||
chatbot:
|
||||
build:
|
||||
context: ../../tuna/tuna
|
||||
dockerfile: Dockerfile
|
||||
image: candidat/chatbot:latest
|
||||
container_name: backend-chatbot
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
||||
- CHATBOT_DB_HOST=database
|
||||
- CHATBOT_DB_NAME=${MYSQL_DATABASE}
|
||||
- CHATBOT_DB_USER=${MYSQL_USER}
|
||||
- CHATBOT_DB_PASSWORD=${MYSQL_PASSWORD}
|
||||
- CHATBOT_DB_PORT=3306
|
||||
ports:
|
||||
- "5000:80"
|
||||
networks:
|
||||
- candidat-network
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
candidat-network:
|
||||
external: true
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+5573
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
Portainer: 168.231.108.135:9443 / tundaadmin / retoortunapass1
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import {join} from "node:path";
|
||||
import {Configuration} from "@tsed/di";
|
||||
import {application} from "@tsed/platform-http";
|
||||
import "@tsed/platform-log-request"; // remove this import if you don't want log request
|
||||
import "@tsed/platform-express"; // /!\ keep this import
|
||||
import "@tsed/ajv";
|
||||
import "@tsed/swagger";
|
||||
import "@tsed/scalar";
|
||||
import {config} from "./config/index.js";
|
||||
import * as rest from "./controllers/rest/index.js";
|
||||
import * as pages from "./controllers/pages/index.js";
|
||||
import {testConnection, closePool} from "./config/database.js";
|
||||
import {$log} from "@tsed/logger";
|
||||
|
||||
@Configuration({
|
||||
...config,
|
||||
acceptMimes: ["application/json"],
|
||||
httpPort: process.env.PORT || 8083,
|
||||
httpsPort: false, // CHANGE
|
||||
mount: {
|
||||
"/rest": [
|
||||
...Object.values(rest)
|
||||
],
|
||||
"/": [
|
||||
...Object.values(pages)
|
||||
]
|
||||
},
|
||||
swagger: [
|
||||
{
|
||||
path: "/doc",
|
||||
specVersion: "3.0.1",
|
||||
spec: {
|
||||
info: {
|
||||
title: "Candivista API",
|
||||
version: process.env.APP_VERSION || "1.0.0",
|
||||
description:
|
||||
"REST API for Candivista. Authentication via JWT Bearer tokens.\n\n" +
|
||||
"Includes endpoints for auth, users, jobs, tokens, AI, and admin reporting.",
|
||||
contact: {
|
||||
name: "Candivista Team",
|
||||
url: "https://candivista.com",
|
||||
email: "support@candivista.com"
|
||||
},
|
||||
license: { name: "Proprietary" }
|
||||
},
|
||||
servers: [
|
||||
{ url: "http://localhost:8083", description: "Local" }
|
||||
],
|
||||
tags: [
|
||||
{ name: "Auth", description: "Authentication and session management" },
|
||||
{ name: "Users", description: "User profile and token summary" },
|
||||
{ name: "Jobs", description: "Job posting and interview token operations" },
|
||||
{ name: "Admin", description: "Administrative statistics and management" },
|
||||
{ name: "AI", description: "AI provider tests and operations" }
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" }
|
||||
}
|
||||
},
|
||||
security: [{ bearerAuth: [] }]
|
||||
}
|
||||
}
|
||||
],
|
||||
scalar: [
|
||||
{
|
||||
path: "/scalar/doc",
|
||||
specVersion: "3.0.1"
|
||||
}
|
||||
],
|
||||
middlewares: [
|
||||
"cors",
|
||||
"cookie-parser",
|
||||
"compression",
|
||||
"method-override",
|
||||
"json-parser",
|
||||
{ use: "urlencoded-parser", options: { extended: true }}
|
||||
],
|
||||
views: {
|
||||
root: join(process.cwd(), "views"),
|
||||
extensions: {
|
||||
ejs: "ejs"
|
||||
}
|
||||
}
|
||||
})
|
||||
export class Server {
|
||||
protected app = application();
|
||||
|
||||
async $onInit() {
|
||||
// Test database connection on startup
|
||||
const isConnected = await testConnection();
|
||||
if (!isConnected) {
|
||||
$log.error("Failed to connect to database. Server will continue but database operations may fail.");
|
||||
}
|
||||
}
|
||||
|
||||
async $onDestroy() {
|
||||
// Close database pool on shutdown
|
||||
await closePool();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* @file Automatically generated by @tsed/barrels.
|
||||
*/
|
||||
export * from "./IndexController.js";
|
||||
@@ -0,0 +1,644 @@
|
||||
import { Controller } from "@tsed/di";
|
||||
import { Post, Get } from "@tsed/schema";
|
||||
import { BodyParams, PathParams, QueryParams } from "@tsed/platform-params";
|
||||
import { Req } from "@tsed/platform-http";
|
||||
import { BadRequest, NotFound } from "@tsed/exceptions";
|
||||
import { JobService } from "../../services/JobService.js";
|
||||
import { AIService } from "../../services/AIService.js";
|
||||
import axios from "axios";
|
||||
|
||||
@Controller("/ai")
|
||||
export class AIController {
|
||||
private jobService = new JobService();
|
||||
private aiService = new AIService();
|
||||
private aiProvider = process.env.AI_PROVIDER || 'ollama'; // 'ollama' or 'openrouter'
|
||||
private aiPort = process.env.AI_PORT || '11434';
|
||||
private aiModel = process.env.AI_MODEL || 'gpt-oss:20b';
|
||||
|
||||
// Test AI connection
|
||||
@Get("/test-ai")
|
||||
async testAI() {
|
||||
try {
|
||||
if (this.aiProvider === 'openrouter') {
|
||||
const response = await this.aiService.generateResponse("Hello, please respond with exactly: 'AI is working'");
|
||||
return {
|
||||
success: true,
|
||||
aiResponse: response,
|
||||
provider: 'openrouter',
|
||||
model: process.env.OPENROUTER_MODEL || 'gemma'
|
||||
};
|
||||
} else {
|
||||
// Ollama test
|
||||
const response = await axios.post(`http://localhost:${this.aiPort}/api/generate`, {
|
||||
model: this.aiModel,
|
||||
prompt: "Hello, please respond with exactly: 'AI is working'",
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.1,
|
||||
max_tokens: 50
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
aiResponse: response.data.response,
|
||||
provider: 'ollama',
|
||||
model: this.aiModel,
|
||||
port: this.aiPort
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('AI test failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
provider: this.aiProvider,
|
||||
model: this.aiProvider === 'openrouter' ? process.env.OPENROUTER_MODEL : this.aiModel
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get mandatory questions for the job
|
||||
@Get("/mandatory-questions/:linkId")
|
||||
async getMandatoryQuestions(@PathParams("linkId") linkId: string) {
|
||||
try {
|
||||
// Verify the job exists and link is valid
|
||||
const jobData = await this.jobService.getJobByLinkId(linkId);
|
||||
if (!jobData) {
|
||||
throw new NotFound("Interview link not found or expired");
|
||||
}
|
||||
|
||||
const mandatoryQuestions = jobData.interview_questions || [];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
questions: mandatoryQuestions,
|
||||
hasMandatoryQuestions: mandatoryQuestions.length > 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting mandatory questions:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Submit mandatory question answers
|
||||
@Post("/submit-mandatory-answers")
|
||||
async submitMandatoryAnswers(@BodyParams() body: any, @QueryParams() query: any) {
|
||||
try {
|
||||
const { candidateName, job, linkId, answers } = body;
|
||||
const isTestMode = query.test === 'true' || body.test === true;
|
||||
|
||||
if (!candidateName || !job || !linkId || !answers) {
|
||||
throw new BadRequest("Missing required fields: candidateName, job, linkId, answers");
|
||||
}
|
||||
|
||||
// Verify the job exists and link is valid
|
||||
const jobData = await this.jobService.getJobByLinkId(linkId);
|
||||
if (!jobData) {
|
||||
throw new NotFound("Interview link not found or expired");
|
||||
}
|
||||
|
||||
// Create or get interview record (skip DB writes in test mode)
|
||||
const interviewId = await this.jobService.getOrCreateInterview(linkId, candidateName, isTestMode);
|
||||
|
||||
// Save all mandatory question answers
|
||||
for (let i = 0; i < answers.length; i++) {
|
||||
const question = jobData.interview_questions[i];
|
||||
const answer = answers[i];
|
||||
|
||||
if (question && answer) {
|
||||
// Save as AI message (question)
|
||||
await this.jobService.saveConversationMessage(interviewId, linkId, 'ai', `Question ${i + 1}: ${question}`, isTestMode);
|
||||
// Save as candidate message (answer)
|
||||
await this.jobService.saveConversationMessage(interviewId, linkId, 'candidate', answer, isTestMode);
|
||||
}
|
||||
}
|
||||
|
||||
// Log mandatory questions completed
|
||||
await this.jobService.logInterviewEvent(linkId, 'mandatory_questions_completed', {
|
||||
candidateName,
|
||||
interviewId,
|
||||
questionsAnswered: answers.length,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Mandatory questions answered successfully",
|
||||
interviewId
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error submitting mandatory answers:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Start interview with AI agent (only after mandatory questions)
|
||||
@Post("/start-interview")
|
||||
async startInterview(@BodyParams() body: any, @QueryParams() query: any) {
|
||||
try {
|
||||
const { candidateName, job, linkId } = body;
|
||||
const isTestMode = query.test === 'true' || body.test === true;
|
||||
|
||||
if (!candidateName || !job || !linkId) {
|
||||
throw new BadRequest("Missing required fields: candidateName, job, linkId");
|
||||
}
|
||||
|
||||
// Verify the job exists and link is valid
|
||||
const jobData = await this.jobService.getJobByLinkId(linkId);
|
||||
if (!jobData) {
|
||||
throw new NotFound("Interview link not found or expired");
|
||||
}
|
||||
|
||||
// Create or get interview record (skip DB writes in test mode)
|
||||
const interviewId = await this.jobService.getOrCreateInterview(linkId, candidateName, isTestMode);
|
||||
|
||||
// Get conversation history to include mandatory question answers
|
||||
let conversationHistory;
|
||||
if (isTestMode) {
|
||||
// In test mode, we can't get conversation history from DB since we don't save
|
||||
// The frontend should pass the mandatory question answers in the request
|
||||
conversationHistory = [];
|
||||
console.log(`[DEBUG] Starting AI in test mode - no conversation history available`);
|
||||
} else {
|
||||
// In production mode, get from database
|
||||
conversationHistory = await this.jobService.getConversationHistory(interviewId);
|
||||
console.log(`[DEBUG] Starting AI with conversation history: ${JSON.stringify(conversationHistory, null, 2)}`);
|
||||
}
|
||||
|
||||
// Generate initial AI message using chatbot service (fail if AI unavailable)
|
||||
const initialMessage = await this.aiService.initializeInterviewWithChatbot(job, candidateName, linkId, conversationHistory);
|
||||
console.log(`[DEBUG] initializeInterviewWithChatbot returned: "${initialMessage}"`);
|
||||
if (!initialMessage) {
|
||||
throw new Error("AI service is currently unavailable. Please try again later.");
|
||||
}
|
||||
|
||||
// Save AI message to conversation
|
||||
await this.jobService.saveConversationMessage(interviewId, linkId, 'ai', initialMessage, isTestMode);
|
||||
|
||||
// Log interview start
|
||||
await this.jobService.logInterviewEvent(linkId, 'started', {
|
||||
candidateName,
|
||||
interviewId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: initialMessage,
|
||||
job: jobData,
|
||||
interviewId
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Error starting interview:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle chat messages
|
||||
@Post("/chat")
|
||||
async handleChat(@BodyParams() body: any, @QueryParams() query: any) {
|
||||
try {
|
||||
const { message, candidateName, job, linkId, conversationHistory } = body;
|
||||
const isTestMode = query.test === 'true' || body.test === true;
|
||||
|
||||
if (!message || !candidateName || !job || !linkId) {
|
||||
throw new BadRequest("Missing required fields: message, candidateName, job, linkId");
|
||||
}
|
||||
|
||||
// Verify the job exists and link is valid
|
||||
const jobData = await this.jobService.getJobByLinkId(linkId);
|
||||
if (!jobData) {
|
||||
throw new NotFound("Interview link not found or expired");
|
||||
}
|
||||
|
||||
// Get or create interview record
|
||||
const interviewId = await this.jobService.getOrCreateInterview(linkId, candidateName, isTestMode);
|
||||
|
||||
// Save user message to conversation
|
||||
await this.jobService.saveConversationMessage(interviewId, linkId, 'candidate', message, isTestMode);
|
||||
|
||||
// Get conversation history - use frontend data in test mode, database in production
|
||||
let conversationHistoryToUse;
|
||||
if (isTestMode) {
|
||||
// In test mode, use the conversation history passed from frontend
|
||||
conversationHistoryToUse = conversationHistory || [];
|
||||
console.log(`[DEBUG] Using frontend conversation history (test mode): ${JSON.stringify(conversationHistoryToUse, null, 2)}`);
|
||||
|
||||
// Filter out any messages with undefined content
|
||||
conversationHistoryToUse = conversationHistoryToUse.filter(msg =>
|
||||
msg && msg.message && msg.message !== 'undefined' && msg.sender
|
||||
);
|
||||
console.log(`[DEBUG] Filtered conversation history: ${JSON.stringify(conversationHistoryToUse, null, 2)}`);
|
||||
} else {
|
||||
// In production mode, get from database
|
||||
conversationHistoryToUse = await this.jobService.getConversationHistory(interviewId);
|
||||
console.log(`[DEBUG] Retrieved conversation history from database: ${JSON.stringify(conversationHistoryToUse, null, 2)}`);
|
||||
}
|
||||
|
||||
// Generate AI response using chatbot service
|
||||
const aiResponse = await this.generateAIResponseWithChatbot(message, job, conversationHistoryToUse, candidateName, linkId);
|
||||
console.log(`[DEBUG] generateAIResponseWithChatbot returned:`, aiResponse);
|
||||
|
||||
if (!aiResponse) {
|
||||
throw new Error("AI service is currently unavailable. Please try again later.");
|
||||
}
|
||||
|
||||
// Save AI response to conversation
|
||||
await this.jobService.saveConversationMessage(interviewId, linkId, 'ai', aiResponse.message, isTestMode);
|
||||
|
||||
// Log the messages
|
||||
await this.jobService.logInterviewEvent(linkId, 'user_message', {
|
||||
candidateName,
|
||||
message,
|
||||
interviewId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
await this.jobService.logInterviewEvent(linkId, 'ai_message', {
|
||||
candidateName,
|
||||
message: aiResponse.message,
|
||||
interviewId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: aiResponse.message,
|
||||
isComplete: aiResponse.isComplete
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Error handling chat:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get conversation history
|
||||
@Get("/conversation/:linkId")
|
||||
async getConversation(@PathParams("linkId") linkId: string) {
|
||||
try {
|
||||
const jobData = await this.jobService.getJobByLinkId(linkId);
|
||||
if (!jobData) {
|
||||
throw new NotFound("Interview link not found or expired");
|
||||
}
|
||||
|
||||
const interviewId = await this.jobService.getInterviewIdByLink(linkId);
|
||||
if (!interviewId) {
|
||||
return {
|
||||
success: true,
|
||||
messages: []
|
||||
};
|
||||
}
|
||||
|
||||
const messages = await this.jobService.getConversationHistory(interviewId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: messages
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Error getting conversation:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// End interview
|
||||
@Post("/end-interview/:linkId")
|
||||
async endInterview(@PathParams("linkId") linkId: string) {
|
||||
try {
|
||||
const jobData = await this.jobService.getJobByLinkId(linkId);
|
||||
if (!jobData) {
|
||||
throw new NotFound("Interview link not found or expired");
|
||||
}
|
||||
|
||||
const interviewId = await this.jobService.getInterviewIdByLink(linkId);
|
||||
if (!interviewId) {
|
||||
throw new NotFound("Interview not found");
|
||||
}
|
||||
|
||||
// End interview with chatbot service
|
||||
await this.aiService.endInterviewWithChatbot(linkId);
|
||||
|
||||
// Mark interview as completed
|
||||
await this.jobService.completeInterview(interviewId);
|
||||
|
||||
// Log interview completion
|
||||
await this.jobService.logInterviewEvent(linkId, 'completed', {
|
||||
interviewId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Interview completed successfully"
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Error ending interview:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async generateInitialMessage(job: any, candidateName: string, conversationHistory: any[] = []): Promise<string | null> {
|
||||
const skills = job.skills_required ? job.skills_required.join(', ') : 'various technical skills';
|
||||
const experience = job.experience_level.replace('_', ' ');
|
||||
|
||||
// Build context from conversation history (mandatory question answers)
|
||||
const conversationContext = conversationHistory
|
||||
.map(msg => `${msg.sender === 'candidate' ? 'Candidate' : 'Interviewer'}: ${msg.message}`)
|
||||
.join('\n');
|
||||
|
||||
const systemMessage = `You are an AI interview agent conducting an interview for the position: ${job.title}
|
||||
|
||||
Job Description: ${job.description}
|
||||
Requirements: ${job.requirements}
|
||||
Required Skills: ${skills}
|
||||
Experience Level: ${experience}
|
||||
Location: ${job.location || 'Remote'}
|
||||
|
||||
${conversationContext ? `Previous conversation (mandatory questions answered):
|
||||
${conversationContext}
|
||||
|
||||
Based on the candidate's answers to the mandatory questions above, you should now conduct a deeper interview.` : ''}
|
||||
|
||||
Your task is to:
|
||||
1. Greet the candidate warmly and professionally
|
||||
2. Introduce yourself as their evaluation agent
|
||||
3. ${conversationContext ? 'Acknowledge their previous answers and build upon them' : 'Explain that you\'ll be conducting a comprehensive interview'}
|
||||
4. Ask them to tell you about themselves and their interest in this role
|
||||
5. Keep your response conversational and engaging
|
||||
6. Don't ask multiple questions at once - start with one open-ended question
|
||||
|
||||
Respond in a friendly, professional tone. Keep it concise but welcoming.`;
|
||||
|
||||
const userPrompt = `The candidate's name is ${candidateName}. Please start the interview.`;
|
||||
|
||||
try {
|
||||
if (this.aiProvider === 'openrouter') {
|
||||
const response = await this.aiService.generateResponse(userPrompt, systemMessage);
|
||||
if (response) {
|
||||
return response;
|
||||
} else {
|
||||
console.log('[WARN] OpenRouter failed, falling back to Ollama');
|
||||
// Fallback to Ollama if OpenRouter fails
|
||||
}
|
||||
}
|
||||
|
||||
// Ollama fallback (either configured or as fallback)
|
||||
const response = await axios.post(`http://localhost:${this.aiPort}/api/generate`, {
|
||||
model: this.aiModel,
|
||||
prompt: `${systemMessage}\n\n${userPrompt}`,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 500
|
||||
}
|
||||
});
|
||||
|
||||
return response.data.response || null;
|
||||
} catch (error) {
|
||||
console.error('Error calling AI:', error);
|
||||
return null; // Return null instead of fallback message
|
||||
}
|
||||
}
|
||||
|
||||
private async generateAIResponseWithChatbot(userMessage: string, job: any, conversationHistory: any[], candidateName: string, linkId: string): Promise<{ message: string; isComplete: boolean } | null> {
|
||||
// Check if we should end the interview (after 10+ exchanges)
|
||||
const userMessages = conversationHistory.filter(msg => msg.sender === 'candidate').length;
|
||||
const shouldEnd = userMessages >= 10;
|
||||
|
||||
if (shouldEnd) {
|
||||
const endPrompt = `The interview is coming to a close. The candidate has provided comprehensive responses about their background and experience for the ${job.title} position.
|
||||
|
||||
Please provide a professional closing message that:
|
||||
1. Thanks the candidate for their time and thoughtful responses
|
||||
2. Acknowledges their qualifications and interest
|
||||
3. Explains that their responses will be reviewed by the hiring team
|
||||
4. Mentions they should expect to hear back within a few business days
|
||||
5. Keeps it warm and professional
|
||||
|
||||
Keep it concise and professional.`;
|
||||
|
||||
try {
|
||||
const response = await this.aiService.generateResponseWithChatbot(
|
||||
endPrompt,
|
||||
conversationHistory,
|
||||
undefined,
|
||||
job,
|
||||
candidateName,
|
||||
linkId
|
||||
);
|
||||
|
||||
return {
|
||||
message: response || "Thank you for your time and detailed responses. That concludes our interview. We'll review your answers and get back to you within a few business days.",
|
||||
isComplete: true
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calling chatbot for end message:', error);
|
||||
return {
|
||||
message: "Thank you for your time and detailed responses. That concludes our interview. We'll review your answers and get back to you within a few business days.",
|
||||
isComplete: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Build context for ongoing conversation
|
||||
const conversationContext = conversationHistory
|
||||
.slice(-6) // Last 6 messages for context
|
||||
.map(msg => `${msg.sender === 'candidate' ? 'Candidate' : 'Interviewer'}: ${msg.message}`)
|
||||
.join('\n');
|
||||
|
||||
// Debug logging
|
||||
console.log(`[DEBUG] Conversation history length: ${conversationHistory.length}`);
|
||||
console.log(`[DEBUG] Conversation context: ${conversationContext}`);
|
||||
console.log(`[DEBUG] User message: ${userMessage}`);
|
||||
|
||||
const systemMessage = `You are an AI interview agent conducting an interview for the position: ${job.title}
|
||||
|
||||
Job Details:
|
||||
- Title: ${job.title}
|
||||
- Description: ${job.description}
|
||||
- Requirements: ${job.requirements}
|
||||
- Required Skills: ${job.skills_required ? job.skills_required.join(', ') : 'Various technical skills'}
|
||||
- Experience Level: ${job.experience_level.replace('_', ' ')}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. You MUST acknowledge the candidate's response first
|
||||
2. You MUST then ask ONE specific follow-up question
|
||||
3. The question should be relevant to their answer and help evaluate their fit for the ${job.title} role
|
||||
4. Focus on technical skills, experience, problem-solving, or behavioral aspects
|
||||
5. Keep the question specific and engaging
|
||||
6. Do NOT repeat the same question
|
||||
7. Do NOT ask multiple questions at once
|
||||
8. Maintain a professional but conversational tone
|
||||
|
||||
RESPONSE FORMAT:
|
||||
- Start with a brief acknowledgment of their answer
|
||||
- Then ask exactly one follow-up question
|
||||
- End your response after the question
|
||||
|
||||
Example:
|
||||
"Thanks for sharing that experience with React. That's exactly the kind of hands-on development we're looking for. Can you tell me about a specific challenge you faced while building that application and how you solved it?"`;
|
||||
|
||||
const userPrompt = `Recent conversation:
|
||||
${conversationContext}
|
||||
|
||||
Candidate's latest response: ${userMessage}
|
||||
|
||||
Please respond with an acknowledgment and follow-up question.`;
|
||||
|
||||
try {
|
||||
const aiResponse = await this.aiService.generateResponseWithChatbot(
|
||||
userMessage,
|
||||
conversationHistory,
|
||||
systemMessage,
|
||||
job,
|
||||
candidateName,
|
||||
linkId
|
||||
);
|
||||
|
||||
if (aiResponse) {
|
||||
console.log(`[DEBUG] Chatbot Response: ${aiResponse}`);
|
||||
return {
|
||||
message: aiResponse,
|
||||
isComplete: false
|
||||
};
|
||||
} else {
|
||||
console.log('[WARN] Chatbot service failed, falling back to direct OpenRouter');
|
||||
// Fallback to original method
|
||||
return await this.generateAIResponse(userMessage, job, conversationHistory, candidateName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error calling chatbot service:', error);
|
||||
// Fallback to original method
|
||||
return await this.generateAIResponse(userMessage, job, conversationHistory, candidateName);
|
||||
}
|
||||
}
|
||||
|
||||
private async generateAIResponse(userMessage: string, job: any, conversationHistory: any[], candidateName: string): Promise<{ message: string; isComplete: boolean } | null> {
|
||||
// Check if we should end the interview (after 10+ exchanges)
|
||||
const userMessages = conversationHistory.filter(msg => msg.sender === 'candidate').length;
|
||||
const shouldEnd = userMessages >= 10;
|
||||
|
||||
if (shouldEnd) {
|
||||
const endPrompt = `The interview is coming to a close. The candidate has provided comprehensive responses about their background and experience for the ${job.title} position.
|
||||
|
||||
Please provide a professional closing message that:
|
||||
1. Thanks the candidate for their time and thoughtful responses
|
||||
2. Acknowledges their qualifications and interest
|
||||
3. Explains that their responses will be reviewed by the hiring team
|
||||
4. Mentions they should expect to hear back within a few business days
|
||||
5. Keeps it warm and professional
|
||||
|
||||
Keep it concise and professional.`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(`http://localhost:${this.aiPort}/api/generate`, {
|
||||
model: this.aiModel,
|
||||
prompt: endPrompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 300
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
message: response.data.response || null,
|
||||
isComplete: true
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calling Ollama for end message:', error);
|
||||
return null; // Return null instead of fallback message
|
||||
}
|
||||
}
|
||||
|
||||
// Build context for ongoing conversation
|
||||
const conversationContext = conversationHistory
|
||||
.slice(-6) // Last 6 messages for context
|
||||
.map(msg => `${msg.sender === 'candidate' ? 'Candidate' : 'Interviewer'}: ${msg.message}`)
|
||||
.join('\n');
|
||||
|
||||
// Debug logging
|
||||
console.log(`[DEBUG] Conversation history length: ${conversationHistory.length}`);
|
||||
console.log(`[DEBUG] Conversation context: ${conversationContext}`);
|
||||
console.log(`[DEBUG] User message: ${userMessage}`);
|
||||
|
||||
const systemMessage = `You are an AI interview agent conducting an interview for the position: ${job.title}
|
||||
|
||||
Job Details:
|
||||
- Title: ${job.title}
|
||||
- Description: ${job.description}
|
||||
- Requirements: ${job.requirements}
|
||||
- Required Skills: ${job.skills_required ? job.skills_required.join(', ') : 'Various technical skills'}
|
||||
- Experience Level: ${job.experience_level.replace('_', ' ')}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. You MUST acknowledge the candidate's response first
|
||||
2. You MUST then ask ONE specific follow-up question
|
||||
3. The question should be relevant to their answer and help evaluate their fit for the ${job.title} role
|
||||
4. Focus on technical skills, experience, problem-solving, or behavioral aspects
|
||||
5. Keep the question specific and engaging
|
||||
6. Do NOT repeat the same question
|
||||
7. Do NOT ask multiple questions at once
|
||||
8. Maintain a professional but conversational tone
|
||||
|
||||
RESPONSE FORMAT:
|
||||
- Start with a brief acknowledgment of their answer
|
||||
- Then ask exactly one follow-up question
|
||||
- End your response after the question
|
||||
|
||||
Example:
|
||||
"Thanks for sharing that experience with React. That's exactly the kind of hands-on development we're looking for. Can you tell me about a specific challenge you faced while building that application and how you solved it?"`;
|
||||
|
||||
const userPrompt = `Recent conversation:
|
||||
${conversationContext}
|
||||
|
||||
Candidate's latest response: ${userMessage}
|
||||
|
||||
Please respond with an acknowledgment and follow-up question.`;
|
||||
|
||||
try {
|
||||
if (this.aiProvider === 'openrouter') {
|
||||
const aiResponse = await this.aiService.generateResponseWithHistory(userMessage, conversationHistory, systemMessage);
|
||||
if (aiResponse) {
|
||||
console.log(`[DEBUG] OpenRouter Response: ${aiResponse}`);
|
||||
return {
|
||||
message: aiResponse,
|
||||
isComplete: false
|
||||
};
|
||||
} else {
|
||||
console.log('[WARN] OpenRouter failed, falling back to Ollama');
|
||||
// Fallback to Ollama if OpenRouter fails
|
||||
}
|
||||
}
|
||||
|
||||
// Ollama fallback (either configured or as fallback)
|
||||
console.log(`[DEBUG] Sending to Ollama - Port: ${this.aiPort}, Model: ${this.aiModel}`);
|
||||
console.log(`[DEBUG] Prompt length: ${systemMessage.length + userPrompt.length} characters`);
|
||||
|
||||
const response = await axios.post(`http://localhost:${this.aiPort}/api/generate`, {
|
||||
model: this.aiModel,
|
||||
prompt: `${systemMessage}\n\n${userPrompt}`,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 400
|
||||
}
|
||||
});
|
||||
|
||||
const aiResponse = response.data.response || null;
|
||||
console.log(`[DEBUG] Ollama Response: ${aiResponse}`);
|
||||
|
||||
return {
|
||||
message: aiResponse,
|
||||
isComplete: false
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calling AI:', error);
|
||||
return null; // Return null instead of fallback message
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Controller } from "@tsed/di";
|
||||
import { Get, Post, Put, Patch, Delete, Tags, Summary, Description, Returns, Security } from "@tsed/schema";
|
||||
import { BodyParams, PathParams, QueryParams } from "@tsed/platform-params";
|
||||
import { Req } from "@tsed/platform-http";
|
||||
import { BadRequest, Unauthorized, NotFound } from "@tsed/exceptions";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { AdminService } from "../../services/AdminService.js";
|
||||
import { UserService } from "../../services/UserService.js";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
|
||||
|
||||
@Controller("/admin")
|
||||
@Tags("Admin")
|
||||
@Security("bearerAuth")
|
||||
export class AdminController {
|
||||
private adminService = new AdminService();
|
||||
private userService = new UserService();
|
||||
|
||||
// Middleware to check if user is admin
|
||||
private async checkAdmin(req: any) {
|
||||
const token = req.headers.authorization?.replace("Bearer ", "");
|
||||
|
||||
if (!token) {
|
||||
throw new Unauthorized("No token provided");
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||
const user = await this.userService.getUserById(decoded.userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Unauthorized("User not found");
|
||||
}
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
throw new Unauthorized("Admin access required");
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
throw new Unauthorized("Invalid token or insufficient permissions");
|
||||
}
|
||||
}
|
||||
|
||||
// System Statistics
|
||||
@Get("/statistics")
|
||||
@Summary("Get system statistics")
|
||||
@Description("High-level metrics: users, jobs, interviews, tokens, revenue")
|
||||
@(Returns(200).Description("Statistics returned"))
|
||||
@(Returns(401).Description("Unauthorized"))
|
||||
async getSystemStatistics(@Req() req: any) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.getSystemStatistics();
|
||||
}
|
||||
|
||||
// User Management
|
||||
@Get("/users")
|
||||
@Summary("List all users")
|
||||
@(Returns(200).Description("Users returned"))
|
||||
async getAllUsers(@Req() req: any) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.getAllUsers();
|
||||
}
|
||||
|
||||
@Get("/users/:id")
|
||||
@Summary("Get a user by ID")
|
||||
@(Returns(200).Description("User returned"))
|
||||
@(Returns(404).Description("User not found"))
|
||||
async getUserById(@Req() req: any, @PathParams("id") id: string) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.getUserById(id);
|
||||
}
|
||||
|
||||
@Put("/users/:id")
|
||||
@Summary("Update a user")
|
||||
@(Returns(200).Description("User updated"))
|
||||
async updateUser(
|
||||
@Req() req: any,
|
||||
@PathParams("id") id: string,
|
||||
@BodyParams() userData: any
|
||||
) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.updateUser(id, userData);
|
||||
}
|
||||
|
||||
@Patch("/users/:id/toggle-status")
|
||||
@Summary("Toggle user active status")
|
||||
@(Returns(200).Description("User status toggled"))
|
||||
async toggleUserStatus(@Req() req: any, @PathParams("id") id: string) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.toggleUserStatus(id);
|
||||
}
|
||||
|
||||
@Patch("/users/:id/password")
|
||||
@Summary("Change user password")
|
||||
@(Returns(200).Description("Password updated"))
|
||||
async changeUserPassword(
|
||||
@Req() req: any,
|
||||
@PathParams("id") id: string,
|
||||
@BodyParams() body: { new_password: string }
|
||||
) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.changeUserPassword(id, body.new_password);
|
||||
}
|
||||
|
||||
@Post("/users")
|
||||
@Summary("Create a user")
|
||||
@(Returns(200).Description("User created"))
|
||||
async createUser(@Req() req: any, @BodyParams() userData: any) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.createUser(userData);
|
||||
}
|
||||
|
||||
// Job Management
|
||||
@Get("/jobs")
|
||||
@Summary("List all jobs")
|
||||
@(Returns(200).Description("Jobs returned"))
|
||||
async getAllJobs(@Req() req: any) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.getAllJobs();
|
||||
}
|
||||
|
||||
@Get("/jobs/:id")
|
||||
@Summary("Get job by ID")
|
||||
@(Returns(200).Description("Job returned"))
|
||||
async getJobById(@Req() req: any, @PathParams("id") id: string) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.getJobById(id);
|
||||
}
|
||||
|
||||
@Patch("/jobs/:id/status")
|
||||
@Summary("Update job status")
|
||||
@(Returns(200).Description("Job status updated"))
|
||||
async updateJobStatus(
|
||||
@Req() req: any,
|
||||
@PathParams("id") id: string,
|
||||
@BodyParams() body: { status: string }
|
||||
) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.updateJobStatus(id, body.status);
|
||||
}
|
||||
|
||||
@Put("/jobs/:id")
|
||||
@Summary("Update job details")
|
||||
@(Returns(200).Description("Job updated"))
|
||||
async updateJob(
|
||||
@Req() req: any,
|
||||
@PathParams("id") id: string,
|
||||
@BodyParams() jobData: any
|
||||
) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.updateJob(id, jobData);
|
||||
}
|
||||
|
||||
// Token Management
|
||||
@Get("/user-token-summaries")
|
||||
@Summary("List user token summaries")
|
||||
@(Returns(200).Description("Summaries returned"))
|
||||
async getUserTokenSummaries(@Req() req: any) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.getUserTokenSummaries();
|
||||
}
|
||||
|
||||
@Post("/add-tokens")
|
||||
@Summary("Add tokens to a user")
|
||||
@(Returns(200).Description("Tokens added"))
|
||||
async addTokensToUser(@Req() req: any, @BodyParams() tokenData: any) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.addTokensToUser(tokenData);
|
||||
}
|
||||
|
||||
@Get("/token-packages")
|
||||
@Summary("List token packages")
|
||||
@(Returns(200).Description("Packages returned"))
|
||||
async getTokenPackages(@Req() req: any) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.getTokenPackages();
|
||||
}
|
||||
|
||||
@Post("/token-packages")
|
||||
@Summary("Create token package")
|
||||
@(Returns(200).Description("Package created"))
|
||||
async createTokenPackage(@Req() req: any, @BodyParams() packageData: any) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.createTokenPackage(packageData);
|
||||
}
|
||||
|
||||
@Put("/token-packages/:id")
|
||||
@Summary("Update token package")
|
||||
@(Returns(200).Description("Package updated"))
|
||||
async updateTokenPackage(
|
||||
@Req() req: any,
|
||||
@PathParams("id") id: string,
|
||||
@BodyParams() packageData: any
|
||||
) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.updateTokenPackage(id, packageData);
|
||||
}
|
||||
|
||||
@Patch("/token-packages/:id/toggle-status")
|
||||
@Summary("Toggle token package active status")
|
||||
@(Returns(200).Description("Package status toggled"))
|
||||
async toggleTokenPackageStatus(@Req() req: any, @PathParams("id") id: string) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.toggleTokenPackageStatus(id);
|
||||
}
|
||||
|
||||
@Delete("/token-packages/:id")
|
||||
@Summary("Delete token package")
|
||||
@(Returns(200).Description("Package deleted"))
|
||||
async deleteTokenPackage(@Req() req: any, @PathParams("id") id: string) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.deleteTokenPackage(id);
|
||||
}
|
||||
|
||||
// Interview Management
|
||||
@Get("/interviews")
|
||||
@Summary("List interviews")
|
||||
@(Returns(200).Description("Interviews returned"))
|
||||
async getAllInterviews(@Req() req: any) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.getAllInterviews();
|
||||
}
|
||||
|
||||
@Get("/interviews/:id")
|
||||
@Summary("Get interview by ID")
|
||||
@(Returns(200).Description("Interview returned"))
|
||||
async getInterviewById(@Req() req: any, @PathParams("id") id: string) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.getInterviewById(id);
|
||||
}
|
||||
|
||||
// Payment Records
|
||||
@Get("/payments")
|
||||
@Summary("List payment records")
|
||||
@(Returns(200).Description("Payments returned"))
|
||||
async getPaymentRecords(@Req() req: any) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.getPaymentRecords();
|
||||
}
|
||||
|
||||
@Get("/payments/:id")
|
||||
@Summary("Get payment by ID")
|
||||
@(Returns(200).Description("Payment returned"))
|
||||
async getPaymentById(@Req() req: any, @PathParams("id") id: string) {
|
||||
await this.checkAdmin(req);
|
||||
return await this.adminService.getPaymentById(id);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {Controller} from "@tsed/di";
|
||||
import {Get} from "@tsed/schema";
|
||||
|
||||
@Controller("/hello-world")
|
||||
export class HelloWorldController {
|
||||
@Get("/")
|
||||
get() {
|
||||
return "hello";
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
import { pool } from '../config/database.js';
|
||||
import { $log } from '@tsed/logger';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export class AdminService {
|
||||
// System Statistics
|
||||
async getSystemStatistics() {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
// Get basic counts
|
||||
const [userStats] = await connection.execute(`
|
||||
SELECT
|
||||
COUNT(*) as total_users,
|
||||
SUM(CASE WHEN is_active = TRUE THEN 1 ELSE 0 END) as active_users
|
||||
FROM users
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
const [jobStats] = await connection.execute(`
|
||||
SELECT COUNT(*) as total_jobs
|
||||
FROM jobs
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
const [interviewStats] = await connection.execute(`
|
||||
SELECT COUNT(*) as total_interviews
|
||||
FROM interviews
|
||||
WHERE status = 'completed'
|
||||
`);
|
||||
|
||||
const [tokenStats] = await connection.execute(`
|
||||
SELECT
|
||||
COALESCE(SUM(quantity), 0) as total_tokens_purchased,
|
||||
COALESCE(SUM(tokens_used), 0) as total_tokens_used
|
||||
FROM interview_tokens
|
||||
`);
|
||||
|
||||
const [revenueStats] = await connection.execute(`
|
||||
SELECT COALESCE(SUM(amount), 0) as total_revenue
|
||||
FROM payment_records
|
||||
WHERE status = 'paid'
|
||||
`);
|
||||
|
||||
const userStatsData = Array.isArray(userStats) ? userStats[0] : userStats;
|
||||
const jobStatsData = Array.isArray(jobStats) ? jobStats[0] : jobStats;
|
||||
const interviewStatsData = Array.isArray(interviewStats) ? interviewStats[0] : interviewStats;
|
||||
const tokenStatsData = Array.isArray(tokenStats) ? tokenStats[0] : tokenStats;
|
||||
const revenueStatsData = Array.isArray(revenueStats) ? revenueStats[0] : revenueStats;
|
||||
|
||||
return {
|
||||
total_users: userStatsData?.total_users || 0,
|
||||
active_users: userStatsData?.active_users || 0,
|
||||
total_jobs: jobStatsData?.total_jobs || 0,
|
||||
total_interviews: interviewStatsData?.total_interviews || 0,
|
||||
total_tokens_purchased: tokenStatsData?.total_tokens_purchased || 0,
|
||||
total_tokens_used: tokenStatsData?.total_tokens_used || 0,
|
||||
total_revenue: revenueStatsData?.total_revenue || 0,
|
||||
generated_at: new Date().toISOString()
|
||||
};
|
||||
} catch (error) {
|
||||
$log.error('Error getting system statistics:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
// User Management
|
||||
async getAllUsers() {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const [rows] = await connection.execute(`
|
||||
SELECT
|
||||
id, email, first_name, last_name, role, company_name,
|
||||
avatar_url, is_active, last_login_at, email_verified_at,
|
||||
created_at, updated_at
|
||||
FROM users
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch (error) {
|
||||
$log.error('Error getting all users:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async getUserById(id: string) {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const [rows] = await connection.execute(`
|
||||
SELECT
|
||||
id, email, first_name, last_name, role, company_name,
|
||||
avatar_url, is_active, last_login_at, email_verified_at,
|
||||
created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`, [id]);
|
||||
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
$log.error('Error getting user by ID:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async updateUser(id: string, userData: any) {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const updateFields = [];
|
||||
const values = [];
|
||||
|
||||
if (userData.first_name) {
|
||||
updateFields.push('first_name = ?');
|
||||
values.push(userData.first_name);
|
||||
}
|
||||
if (userData.last_name) {
|
||||
updateFields.push('last_name = ?');
|
||||
values.push(userData.last_name);
|
||||
}
|
||||
if (userData.email) {
|
||||
updateFields.push('email = ?');
|
||||
values.push(userData.email);
|
||||
}
|
||||
if (userData.role) {
|
||||
updateFields.push('role = ?');
|
||||
values.push(userData.role);
|
||||
}
|
||||
if (userData.company_name !== undefined) {
|
||||
updateFields.push('company_name = ?');
|
||||
values.push(userData.company_name);
|
||||
}
|
||||
if (userData.avatar_url !== undefined) {
|
||||
updateFields.push('avatar_url = ?');
|
||||
values.push(userData.avatar_url);
|
||||
}
|
||||
if (userData.is_active !== undefined) {
|
||||
updateFields.push('is_active = ?');
|
||||
values.push(userData.is_active);
|
||||
}
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
updateFields.push('updated_at = NOW()');
|
||||
values.push(id);
|
||||
|
||||
await connection.execute(
|
||||
`UPDATE users SET ${updateFields.join(', ')} WHERE id = ? AND deleted_at IS NULL`,
|
||||
values
|
||||
);
|
||||
|
||||
return await this.getUserById(id);
|
||||
} catch (error) {
|
||||
$log.error('Error updating user:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async toggleUserStatus(id: string) {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
// Get current status
|
||||
const [rows] = await connection.execute(
|
||||
'SELECT is_active FROM users WHERE id = ? AND deleted_at IS NULL',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (Array.isArray(rows) && rows.length === 0) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const currentStatus = Array.isArray(rows) ? rows[0] : rows;
|
||||
const newStatus = !currentStatus.is_active;
|
||||
|
||||
await connection.execute(
|
||||
'UPDATE users SET is_active = ?, updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
|
||||
[newStatus, id]
|
||||
);
|
||||
|
||||
return { success: true, new_status: newStatus };
|
||||
} catch (error) {
|
||||
$log.error('Error toggling user status:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async changeUserPassword(id: string, newPassword: string) {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const password_hash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
await connection.execute(
|
||||
'UPDATE users SET password_hash = ?, updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
|
||||
[password_hash, id]
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
$log.error('Error changing user password:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async createUser(userData: any) {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
// Check if user already exists
|
||||
const [existingUsers] = await connection.execute(
|
||||
'SELECT id FROM users WHERE email = ? AND deleted_at IS NULL',
|
||||
[userData.email]
|
||||
);
|
||||
|
||||
if (Array.isArray(existingUsers) && existingUsers.length > 0) {
|
||||
throw new Error('User with this email already exists');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const password_hash = await bcrypt.hash(userData.password, 10);
|
||||
|
||||
// Generate UUID for user ID
|
||||
const userId = randomUUID();
|
||||
|
||||
// Insert user
|
||||
await connection.execute(
|
||||
`INSERT INTO users (id, email, password_hash, first_name, last_name, role, company_name, is_active, email_verified_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW(), NOW())`,
|
||||
[
|
||||
userId,
|
||||
userData.email,
|
||||
password_hash,
|
||||
userData.first_name,
|
||||
userData.last_name,
|
||||
userData.role || 'recruiter',
|
||||
userData.company_name || null,
|
||||
true
|
||||
]
|
||||
);
|
||||
|
||||
// Initialize usage tracking
|
||||
await connection.execute(
|
||||
'INSERT INTO user_usage (user_id) VALUES (?)',
|
||||
[userId]
|
||||
);
|
||||
|
||||
return await this.getUserById(userId);
|
||||
} catch (error) {
|
||||
$log.error('Error creating user:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Job Management
|
||||
async getAllJobs() {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const [rows] = await connection.execute(`
|
||||
SELECT
|
||||
j.*,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.email,
|
||||
u.company_name
|
||||
FROM jobs j
|
||||
LEFT JOIN users u ON j.user_id = u.id
|
||||
WHERE j.deleted_at IS NULL
|
||||
ORDER BY j.created_at DESC
|
||||
`);
|
||||
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch (error) {
|
||||
$log.error('Error getting all jobs:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async getJobById(id: string) {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const [rows] = await connection.execute(`
|
||||
SELECT
|
||||
j.*,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.email,
|
||||
u.company_name
|
||||
FROM jobs j
|
||||
LEFT JOIN users u ON j.user_id = u.id
|
||||
WHERE j.id = ? AND j.deleted_at IS NULL
|
||||
`, [id]);
|
||||
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
$log.error('Error getting job by ID:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async updateJobStatus(id: string, status: string) {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
await connection.execute(
|
||||
'UPDATE jobs SET status = ?, updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
|
||||
[status, id]
|
||||
);
|
||||
|
||||
return { success: true, new_status: status };
|
||||
} catch (error) {
|
||||
$log.error('Error updating job status:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async updateJob(id: string, jobData: any) {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const updateFields = [];
|
||||
const values = [];
|
||||
|
||||
if (jobData.title) {
|
||||
updateFields.push('title = ?');
|
||||
values.push(jobData.title);
|
||||
}
|
||||
if (jobData.description) {
|
||||
updateFields.push('description = ?');
|
||||
values.push(jobData.description);
|
||||
}
|
||||
if (jobData.requirements) {
|
||||
updateFields.push('requirements = ?');
|
||||
values.push(jobData.requirements);
|
||||
}
|
||||
if (jobData.skills_required) {
|
||||
updateFields.push('skills_required = ?');
|
||||
values.push(JSON.stringify(jobData.skills_required));
|
||||
}
|
||||
if (jobData.location) {
|
||||
updateFields.push('location = ?');
|
||||
values.push(jobData.location);
|
||||
}
|
||||
if (jobData.employment_type) {
|
||||
updateFields.push('employment_type = ?');
|
||||
values.push(jobData.employment_type);
|
||||
}
|
||||
if (jobData.experience_level) {
|
||||
updateFields.push('experience_level = ?');
|
||||
values.push(jobData.experience_level);
|
||||
}
|
||||
if (jobData.salary_min !== undefined) {
|
||||
updateFields.push('salary_min = ?');
|
||||
values.push(jobData.salary_min);
|
||||
}
|
||||
if (jobData.salary_max !== undefined) {
|
||||
updateFields.push('salary_max = ?');
|
||||
values.push(jobData.salary_max);
|
||||
}
|
||||
if (jobData.currency) {
|
||||
updateFields.push('currency = ?');
|
||||
values.push(jobData.currency);
|
||||
}
|
||||
if (jobData.status) {
|
||||
updateFields.push('status = ?');
|
||||
values.push(jobData.status);
|
||||
}
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
updateFields.push('updated_at = NOW()');
|
||||
values.push(id);
|
||||
|
||||
await connection.execute(
|
||||
`UPDATE jobs SET ${updateFields.join(', ')} WHERE id = ? AND deleted_at IS NULL`,
|
||||
values
|
||||
);
|
||||
|
||||
return await this.getJobById(id);
|
||||
} catch (error) {
|
||||
$log.error('Error updating job:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Token Management
|
||||
async getUserTokenSummaries() {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const [rows] = await connection.execute(`
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.email,
|
||||
COALESCE(SUM(it.quantity), 0) as total_purchased,
|
||||
COALESCE(SUM(it.tokens_used), 0) as total_used,
|
||||
COALESCE(SUM(it.tokens_remaining), 0) as total_available,
|
||||
CASE
|
||||
WHEN SUM(it.quantity) > 0 THEN ROUND((SUM(it.tokens_used) / SUM(it.quantity)) * 100, 2)
|
||||
ELSE 0
|
||||
END as utilization_percentage
|
||||
FROM users u
|
||||
LEFT JOIN interview_tokens it ON u.id = it.user_id AND it.status = 'active'
|
||||
WHERE u.deleted_at IS NULL
|
||||
GROUP BY u.id, u.first_name, u.last_name, u.email
|
||||
ORDER BY u.created_at DESC
|
||||
`);
|
||||
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch (error) {
|
||||
$log.error('Error getting user token summaries:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async addTokensToUser(tokenData: any) {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const { user_id, quantity, price_per_token } = tokenData;
|
||||
const total_price = quantity * price_per_token;
|
||||
const tokenId = randomUUID();
|
||||
|
||||
// Create token record
|
||||
await connection.execute(`
|
||||
INSERT INTO interview_tokens (
|
||||
id, user_id, token_type, quantity, price_per_token,
|
||||
total_price, status, purchased_at, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'active', NOW(), NOW(), NOW())
|
||||
`, [
|
||||
tokenId,
|
||||
user_id,
|
||||
quantity === 1 ? 'single' : 'bulk',
|
||||
quantity,
|
||||
price_per_token,
|
||||
total_price
|
||||
]);
|
||||
|
||||
// No payment record needed for admin-granted tokens
|
||||
|
||||
// Update user usage
|
||||
await connection.execute(`
|
||||
INSERT INTO user_usage (user_id, tokens_purchased)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE tokens_purchased = tokens_purchased + ?
|
||||
`, [user_id, quantity, quantity]);
|
||||
|
||||
return { success: true, token_id: tokenId };
|
||||
} catch (error) {
|
||||
$log.error('Error adding tokens to user:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Token Packages
|
||||
async getTokenPackages() {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const [rows] = await connection.execute(`
|
||||
SELECT * FROM token_packages
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch (error) {
|
||||
$log.error('Error getting token packages:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async createTokenPackage(packageData: any) {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const packageId = randomUUID();
|
||||
|
||||
await connection.execute(`
|
||||
INSERT INTO token_packages (
|
||||
id, name, description, quantity, price_per_token,
|
||||
total_price, discount_percentage, is_popular, is_active,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
`, [
|
||||
packageId,
|
||||
packageData.name,
|
||||
packageData.description,
|
||||
packageData.quantity,
|
||||
packageData.price_per_token,
|
||||
packageData.total_price,
|
||||
packageData.discount_percentage || 0,
|
||||
packageData.is_popular || false,
|
||||
packageData.is_active !== false
|
||||
]);
|
||||
|
||||
return { success: true, package_id: packageId };
|
||||
} catch (error) {
|
||||
$log.error('Error creating token package:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async updateTokenPackage(id: string, packageData: any) {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const updateFields = [];
|
||||
const values = [];
|
||||
|
||||
if (packageData.name) {
|
||||
updateFields.push('name = ?');
|
||||
values.push(packageData.name);
|
||||
}
|
||||
if (packageData.description) {
|
||||
updateFields.push('description = ?');
|
||||
values.push(packageData.description);
|
||||
}
|
||||
if (packageData.quantity) {
|
||||
updateFields.push('quantity = ?');
|
||||
values.push(packageData.quantity);
|
||||
}
|
||||
if (packageData.price_per_token) {
|
||||
updateFields.push('price_per_token = ?');
|
||||
values.push(packageData.price_per_token);
|
||||
}
|
||||
if (packageData.total_price) {
|
||||
updateFields.push('total_price = ?');
|
||||
values.push(packageData.total_price);
|
||||
}
|
||||
if (packageData.discount_percentage !== undefined) {
|
||||
updateFields.push('discount_percentage = ?');
|
||||
values.push(packageData.discount_percentage);
|
||||
}
|
||||
if (packageData.is_popular !== undefined) {
|
||||
updateFields.push('is_popular = ?');
|
||||
values.push(packageData.is_popular);
|
||||
}
|
||||
if (packageData.is_active !== undefined) {
|
||||
updateFields.push('is_active = ?');
|
||||
values.push(packageData.is_active);
|
||||
}
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
updateFields.push('updated_at = NOW()');
|
||||
values.push(id);
|
||||
|
||||
await connection.execute(
|
||||
`UPDATE token_packages SET ${updateFields.join(', ')} WHERE id = ?`,
|
||||
values
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
$log.error('Error updating token package:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async toggleTokenPackageStatus(id: string) {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
// Get current status
|
||||
const [rows] = await connection.execute(
|
||||
'SELECT is_active FROM token_packages WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (Array.isArray(rows) && rows.length === 0) {
|
||||
throw new Error('Token package not found');
|
||||
}
|
||||
|
||||
const currentStatus = Array.isArray(rows) ? rows[0] : rows;
|
||||
const newStatus = !currentStatus.is_active;
|
||||
|
||||
await connection.execute(
|
||||
'UPDATE token_packages SET is_active = ?, updated_at = NOW() WHERE id = ?',
|
||||
[newStatus, id]
|
||||
);
|
||||
|
||||
return { success: true, new_status: newStatus };
|
||||
} catch (error) {
|
||||
$log.error('Error toggling token package status:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTokenPackage(id: string) {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
await connection.execute(
|
||||
'DELETE FROM token_packages WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
$log.error('Error deleting token package:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Interview Management
|
||||
async getAllInterviews() {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const [rows] = await connection.execute(`
|
||||
SELECT
|
||||
i.*,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.email,
|
||||
j.title as job_title
|
||||
FROM interviews i
|
||||
LEFT JOIN users u ON i.user_id = u.id
|
||||
LEFT JOIN jobs j ON i.job_id = j.id
|
||||
ORDER BY i.created_at DESC
|
||||
`);
|
||||
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch (error) {
|
||||
$log.error('Error getting all interviews:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async getInterviewById(id: string) {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const [rows] = await connection.execute(`
|
||||
SELECT
|
||||
i.*,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.email,
|
||||
j.title as job_title
|
||||
FROM interviews i
|
||||
LEFT JOIN users u ON i.user_id = u.id
|
||||
LEFT JOIN jobs j ON i.job_id = j.id
|
||||
WHERE i.id = ?
|
||||
`, [id]);
|
||||
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
$log.error('Error getting interview by ID:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Payment Records
|
||||
async getPaymentRecords() {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const [rows] = await connection.execute(`
|
||||
SELECT
|
||||
pr.*,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.email,
|
||||
tp.name as package_name
|
||||
FROM payment_records pr
|
||||
LEFT JOIN users u ON pr.user_id = u.id
|
||||
LEFT JOIN token_packages tp ON pr.token_package_id = tp.id
|
||||
ORDER BY pr.created_at DESC
|
||||
`);
|
||||
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch (error) {
|
||||
$log.error('Error getting payment records:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async getPaymentById(id: string) {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const [rows] = await connection.execute(`
|
||||
SELECT
|
||||
pr.*,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.email,
|
||||
tp.name as package_name
|
||||
FROM payment_records pr
|
||||
LEFT JOIN users u ON pr.user_id = u.id
|
||||
LEFT JOIN token_packages tp ON pr.token_package_id = tp.id
|
||||
WHERE pr.id = ?
|
||||
`, [id]);
|
||||
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
$log.error('Error getting payment by ID:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||
|
||||
export interface ChatbotRequest {
|
||||
message: string;
|
||||
conversationHistory?: any[];
|
||||
job?: any;
|
||||
candidateName?: string;
|
||||
linkId?: string;
|
||||
systemMessage?: string;
|
||||
}
|
||||
|
||||
export interface ChatbotResponse {
|
||||
ok: boolean;
|
||||
reply?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ChatbotHealthResponse {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export class ChatbotService {
|
||||
private client: AxiosInstance;
|
||||
private baseUrl: string;
|
||||
private timeout: number;
|
||||
private fallbackEnabled: boolean;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = process.env.CHATBOT_SERVICE_URL || 'http://chatbot:80';
|
||||
this.timeout = parseInt(process.env.CHATBOT_SERVICE_TIMEOUT || '30000');
|
||||
this.fallbackEnabled = process.env.CHATBOT_FALLBACK_ENABLED === 'true';
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: this.timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[DEBUG] ChatbotService initialized:`);
|
||||
console.log(`[DEBUG] - Base URL: ${this.baseUrl}`);
|
||||
console.log(`[DEBUG] - Timeout: ${this.timeout}ms`);
|
||||
console.log(`[DEBUG] - Fallback Enabled: ${this.fallbackEnabled}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if chatbot service is healthy
|
||||
*/
|
||||
async isHealthy(): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.client.get('/api/health');
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Chatbot service health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat message to the chatbot service
|
||||
*/
|
||||
async sendMessage(request: ChatbotRequest): Promise<string | null> {
|
||||
try {
|
||||
console.log(`[DEBUG] Sending message to chatbot service: ${request.message.substring(0, 100)}...`);
|
||||
|
||||
const response: AxiosResponse<ChatbotResponse> = await this.client.post('/api/chat', {
|
||||
message: request.message,
|
||||
conversationHistory: request.conversationHistory,
|
||||
job: request.job,
|
||||
candidateName: request.candidateName,
|
||||
linkId: request.linkId,
|
||||
systemMessage: request.systemMessage
|
||||
});
|
||||
|
||||
if (response.data.ok && response.data.reply) {
|
||||
console.log(`[DEBUG] Chatbot service response received: ${response.data.reply.substring(0, 100)}...`);
|
||||
return response.data.reply;
|
||||
} else {
|
||||
console.error('[ERROR] Chatbot service returned error:', response.data.error);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Chatbot service request failed:', error);
|
||||
if (error.response) {
|
||||
console.error('[ERROR] Response status:', error.response.status);
|
||||
console.error('[ERROR] Response data:', error.response.data);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize an interview with the chatbot service
|
||||
*/
|
||||
async initializeInterview(job: any, candidateName: string, linkId: string, conversationHistory: any[] = []): Promise<string | null> {
|
||||
try {
|
||||
console.log(`[DEBUG] Initializing interview with chatbot service for ${candidateName}`);
|
||||
|
||||
const response: AxiosResponse<ChatbotResponse> = await this.client.post('/api/interview/start', {
|
||||
job,
|
||||
candidateName,
|
||||
linkId,
|
||||
conversationHistory
|
||||
});
|
||||
|
||||
if (response.data.ok && response.data.reply) {
|
||||
console.log(`[DEBUG] Interview initialized successfully`);
|
||||
return response.data.reply;
|
||||
} else {
|
||||
console.error('[ERROR] Failed to initialize interview:', response.data.error);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Interview initialization failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End an interview with the chatbot service
|
||||
*/
|
||||
async endInterview(linkId: string): Promise<boolean> {
|
||||
try {
|
||||
console.log(`[DEBUG] Ending interview with chatbot service for linkId: ${linkId}`);
|
||||
|
||||
const response: AxiosResponse<ChatbotResponse> = await this.client.post('/api/interview/end', {
|
||||
linkId
|
||||
});
|
||||
|
||||
if (response.data.ok) {
|
||||
console.log(`[DEBUG] Interview ended successfully`);
|
||||
return true;
|
||||
} else {
|
||||
console.error('[ERROR] Failed to end interview:', response.data.error);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Interview end failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get interview status from chatbot service
|
||||
*/
|
||||
async getInterviewStatus(linkId: string): Promise<any | null> {
|
||||
try {
|
||||
const response: AxiosResponse<ChatbotResponse> = await this.client.get(`/api/interview/status/${linkId}`);
|
||||
|
||||
if (response.data.ok) {
|
||||
return response.data;
|
||||
} else {
|
||||
console.error('[ERROR] Failed to get interview status:', response.data.error);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Get interview status failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fallback to direct AI service should be used
|
||||
*/
|
||||
shouldUseFallback(): boolean {
|
||||
return this.fallbackEnabled;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user