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,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
|
||||
};
|
||||
Reference in New Issue
Block a user