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:
2025-09-20 10:45:21 +02:00
parent bcd25503c5
commit ec8342b5e2
128 changed files with 27581 additions and 10 deletions
+101
View File
@@ -0,0 +1,101 @@
import {join} from "node:path";
import {Configuration} from "@tsed/di";
import {application} from "@tsed/platform-http";
import "@tsed/platform-log-request"; // remove this import if you don't want log request
import "@tsed/platform-express"; // /!\ keep this import
import "@tsed/ajv";
import "@tsed/swagger";
import "@tsed/scalar";
import {config} from "./config/index.js";
import * as rest from "./controllers/rest/index.js";
import * as pages from "./controllers/pages/index.js";
import {testConnection, closePool} from "./config/database.js";
import {$log} from "@tsed/logger";
@Configuration({
...config,
acceptMimes: ["application/json"],
httpPort: process.env.PORT || 8083,
httpsPort: false, // CHANGE
mount: {
"/rest": [
...Object.values(rest)
],
"/": [
...Object.values(pages)
]
},
swagger: [
{
path: "/doc",
specVersion: "3.0.1",
spec: {
info: {
title: "Candivista API",
version: process.env.APP_VERSION || "1.0.0",
description:
"REST API for Candivista. Authentication via JWT Bearer tokens.\n\n" +
"Includes endpoints for auth, users, jobs, tokens, AI, and admin reporting.",
contact: {
name: "Candivista Team",
url: "https://candivista.com",
email: "support@candivista.com"
},
license: { name: "Proprietary" }
},
servers: [
{ url: "http://localhost:8083", description: "Local" }
],
tags: [
{ name: "Auth", description: "Authentication and session management" },
{ name: "Users", description: "User profile and token summary" },
{ name: "Jobs", description: "Job posting and interview token operations" },
{ name: "Admin", description: "Administrative statistics and management" },
{ name: "AI", description: "AI provider tests and operations" }
],
components: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" }
}
},
security: [{ bearerAuth: [] }]
}
}
],
scalar: [
{
path: "/scalar/doc",
specVersion: "3.0.1"
}
],
middlewares: [
"cors",
"cookie-parser",
"compression",
"method-override",
"json-parser",
{ use: "urlencoded-parser", options: { extended: true }}
],
views: {
root: join(process.cwd(), "views"),
extensions: {
ejs: "ejs"
}
}
})
export class Server {
protected app = application();
async $onInit() {
// Test database connection on startup
const isConnected = await testConnection();
if (!isConnected) {
$log.error("Failed to connect to database. Server will continue but database operations may fail.");
}
}
async $onDestroy() {
// Close database pool on shutdown
await closePool();
}
}
+54
View File
@@ -0,0 +1,54 @@
import mysql from 'mysql2/promise';
import { $log } from '@tsed/logger';
export interface DatabaseConfig {
host: string;
port: number;
user: string;
password: string;
database: string;
connectionLimit: number;
}
const config: DatabaseConfig = {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'candidb_main',
connectionLimit: parseInt(process.env.DB_CONNECTION_LIMIT || '10')
};
// Create connection pool
export const pool = mysql.createPool({
...config,
waitForConnections: true,
queueLimit: 0,
acquireTimeout: 60000,
timeout: 60000,
reconnect: true
});
// Test database connection
export async function testConnection(): Promise<boolean> {
try {
const connection = await pool.getConnection();
await connection.ping();
connection.release();
$log.info('Database connection established successfully');
return true;
} catch (error) {
$log.error('Database connection failed:', error);
return false;
}
}
// Graceful shutdown
export async function closePool(): Promise<void> {
try {
await pool.end();
$log.info('Database pool closed');
} catch (error) {
$log.error('Error closing database pool:', error);
}
}
+7
View File
@@ -0,0 +1,7 @@
import dotenv from "dotenv-flow";
process.env.NODE_ENV = process.env.NODE_ENV || "development";
export const config = dotenv.config();
export const isProduction = process.env.NODE_ENV === "production";
export const envs = process.env
+14
View File
@@ -0,0 +1,14 @@
import {readFileSync} from "node:fs";
import {envs} from "./envs/index.js";
import loggerConfig from "./logger/index.js";
const pkg = JSON.parse(readFileSync("./package.json", {encoding: "utf8"}));
export const config: Partial<TsED.Configuration> = {
version: pkg.version,
envs,
ajv: {
returnsCoercedValues: true
},
logger: loggerConfig,
// additional shared configuration
};
+25
View File
@@ -0,0 +1,25 @@
import {DILoggerOptions} from "@tsed/di";
import {$log} from "@tsed/logger";
import {isProduction} from "../envs/index.js";
if (isProduction) {
$log.appenders.set("stdout", {
type: "stdout",
levels: ["info", "debug"],
layout: {
type: "json"
}
});
$log.appenders.set("stderr", {
levels: ["trace", "fatal", "error", "warn"],
type: "stderr",
layout: {
type: "json"
}
});
}
export default <DILoggerOptions> {
disableRoutesSummary: isProduction
};
@@ -0,0 +1,29 @@
import {Constant, Controller} from "@tsed/di";
import {HeaderParams} from "@tsed/platform-params";
import {View} from "@tsed/platform-views";
import {SwaggerSettings} from "@tsed/swagger";
import {Hidden, Get, Returns} from "@tsed/schema";
@Hidden()
@Controller("/")
export class IndexController {
@Constant("swagger", [])
private swagger: SwaggerSettings[];
@Get("/")
@View("swagger.ejs")
@(Returns(200, String).ContentType("text/html"))
get(@HeaderParams("x-forwarded-proto") protocol: string, @HeaderParams("host") host: string) {
const hostUrl = `${protocol || "http"}://${host}`;
return {
BASE_URL: hostUrl,
docs: this.swagger.map((conf) => {
return {
url: hostUrl + conf.path,
...conf
};
})
};
}
}
+4
View File
@@ -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
};
}
}
+9
View File
@@ -0,0 +1,9 @@
/**
* @file Automatically generated by @tsed/barrels.
*/
export * from "./AIController.js";
export * from "./AdminController.js";
export * from "./AuthController.js";
export * from "./HelloWorldController.js";
export * from "./JobController.js";
export * from "./UserController.js";
+36
View File
@@ -0,0 +1,36 @@
import {$log} from "@tsed/logger";
import { PlatformExpress } from "@tsed/platform-express";
import {Server} from "./Server.js";
const SIG_EVENTS = [
"beforeExit",
"SIGHUP",
"SIGINT",
"SIGQUIT",
"SIGILL",
"SIGTRAP",
"SIGABRT",
"SIGBUS",
"SIGFPE",
"SIGUSR1",
"SIGSEGV",
"SIGUSR2",
"SIGTERM"
];
try {
const platform = await PlatformExpress.bootstrap(Server);
await platform.listen();
SIG_EVENTS.forEach((evt) => process.on(evt, () => platform.stop()));
["uncaughtException", "unhandledRejection"].forEach((evt) =>
process.on(evt, async (error) => {
$log.error({event: "SERVER_" + evt.toUpperCase(), message: error.message, stack: error.stack});
await platform.stop();
})
);
} catch (error) {
$log.error({event: "SERVER_BOOTSTRAP_ERROR", message: error.message, stack: error.stack});
}
+51
View File
@@ -0,0 +1,51 @@
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { UserService } from "../services/UserService.js";
import { Unauthorized } from "@tsed/exceptions";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
export interface AuthenticatedRequest extends Request {
user?: {
id: string;
email: string;
role: string;
first_name: string;
last_name: string;
};
}
export async function adminAuth(req: AuthenticatedRequest, res: Response, next: NextFunction) {
try {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
throw new Unauthorized("No token provided");
}
const decoded = jwt.verify(token, JWT_SECRET) as any;
const userService = new UserService();
const user = await userService.getUserById(decoded.userId);
if (!user) {
throw new Unauthorized("User not found");
}
if (user.role !== 'admin') {
throw new Unauthorized("Admin access required");
}
// Add user info to request
req.user = {
id: user.id,
email: user.email,
role: user.role,
first_name: user.first_name,
last_name: user.last_name
};
next();
} catch (error) {
next(new Unauthorized("Invalid token or insufficient permissions"));
}
}
+66
View File
@@ -0,0 +1,66 @@
export interface User {
id: string;
email: string;
password_hash: string;
first_name: string;
last_name: string;
role: 'admin' | 'recruiter';
company_name?: string;
avatar_url?: string;
is_active: boolean;
last_login_at?: Date;
email_verified_at?: Date;
created_at: Date;
updated_at: Date;
deleted_at?: Date;
}
export type LoginRequest = {
email: string;
password: string;
}
export type RegisterRequest = {
email: string;
password: string;
first_name: string;
last_name: string;
company_name?: string;
}
export type CreateUserRequest = {
email: string;
password: string;
first_name: string;
last_name: string;
company_name?: string;
role?: 'admin' | 'recruiter';
}
export type UpdateUserRequest = {
first_name?: string;
last_name?: string;
company_name?: string;
avatar_url?: string;
is_active?: boolean;
}
export type UserResponse = {
id: string;
email: string;
first_name: string;
last_name: string;
role: 'admin' | 'recruiter';
company_name?: string;
avatar_url?: string;
is_active: boolean;
last_login_at?: Date;
email_verified_at?: Date;
created_at: Date;
updated_at: Date;
}
export type LoginResponse = {
token: string;
user: UserResponse;
}
+294
View File
@@ -0,0 +1,294 @@
import axios from 'axios';
import { ChatbotService } from './ChatbotService.js';
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface ChatRequest {
model: string;
messages: ChatMessage[];
temperature: number;
}
export interface ChatChoice {
message: ChatMessage;
}
export interface ChatResponse {
choices: ChatChoice[];
}
export class AIService {
private apiKey: string;
private model: string;
private baseUrl: string;
private relPath: string;
private temperature: number;
private chatbotService: ChatbotService;
// Predefined models from your C# code
private static readonly PREDEFINED_MODELS: Record<string, string> = {
'dobby': 'sentientagi/dobby-mini-unhinged-plus-llama-3.1-8b',
'dolphin': 'cognitivecomputations/dolphin-mixtral-8x22b',
'dolphin_free': 'cognitivecomputations/dolphin3.0-mistral-24b:free',
'gemma': 'google/gemma-3-12b-it',
'gpt-4o-mini': 'openai/gpt-4o-mini',
'gpt-4.1-nano': 'openai/gpt-4.1-nano',
'qwen': 'qwen/qwen3-30b-a3b',
'unslop': 'thedrummer/unslopnemo-12b',
'euryale': 'sao10k/l3.3-euryale-70b',
'wizard': 'microsoft/wizardlm-2-8x22b',
'deepseek': 'deepseek/deepseek-chat-v3-0324'
};
constructor() {
this.apiKey = process.env.OPENROUTER_API_KEY || 'sk-or-REPLACE_ME';
this.model = process.env.OPENROUTER_MODEL || 'gemma';
this.baseUrl = process.env.OPENROUTER_BASE_URL || 'openrouter.ai';
this.relPath = process.env.OPENROUTER_REL_PATH || '/api';
this.temperature = parseFloat(process.env.OPENROUTER_TEMPERATURE || '0.7');
this.chatbotService = new ChatbotService();
// Map predefined model names to full model names
if (AIService.PREDEFINED_MODELS[this.model]) {
this.model = AIService.PREDEFINED_MODELS[this.model];
}
console.log(`[DEBUG] AIService initialized:`);
console.log(`[DEBUG] - API Key: ${this.apiKey.substring(0, 10)}...`);
console.log(`[DEBUG] - Model: ${this.model}`);
console.log(`[DEBUG] - Base URL: ${this.baseUrl}`);
console.log(`[DEBUG] - Rel Path: ${this.relPath}`);
console.log(`[DEBUG] - Temperature: ${this.temperature}`);
console.log(`[DEBUG] - Chatbot Service: ${this.chatbotService ? 'Enabled' : 'Disabled'}`);
}
async generateResponse(prompt: string, systemMessage?: string): Promise<string | null> {
try {
const messages: ChatMessage[] = [];
if (systemMessage) {
messages.push({ role: 'system', content: systemMessage });
}
messages.push({ role: 'user', content: prompt });
const payload: ChatRequest = {
model: this.model,
messages: messages,
temperature: this.temperature
};
const url = `https://${this.baseUrl}${this.relPath}/v1/chat/completions`;
console.log(`[DEBUG] Sending to OpenRouter - Model: ${this.model}, URL: ${url}`);
console.log(`[DEBUG] Prompt length: ${prompt.length} characters`);
const response = await axios.post(url, payload, {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
const data = response.data as ChatResponse;
const aiResponse = data.choices?.[0]?.message?.content || null;
console.log(`[DEBUG] OpenRouter Response: ${aiResponse}`);
return aiResponse;
} catch (error) {
console.error('Error calling OpenRouter:', error);
if (error.response) {
console.error('Response status:', error.response.status);
console.error('Response data:', error.response.data);
}
return null;
}
}
async generateResponseWithHistory(
userMessage: string,
conversationHistory: any[],
systemMessage?: string
): Promise<string | null> {
try {
const messages: ChatMessage[] = [];
if (systemMessage) {
messages.push({ role: 'system', content: systemMessage });
}
// Add conversation history
conversationHistory.forEach(msg => {
if (msg.sender === 'candidate' || msg.sender === 'user') {
messages.push({ role: 'user', content: msg.message });
} else if (msg.sender === 'ai' || msg.sender === 'assistant') {
messages.push({ role: 'assistant', content: msg.message });
}
});
// Add current user message
messages.push({ role: 'user', content: userMessage });
const payload: ChatRequest = {
model: this.model,
messages: messages,
temperature: this.temperature
};
const url = `https://${this.baseUrl}${this.relPath}/v1/chat/completions`;
console.log(`[DEBUG] Sending to OpenRouter with history - Model: ${this.model}`);
console.log(`[DEBUG] Messages count: ${messages.length}`);
const response = await axios.post(url, payload, {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
const data = response.data as ChatResponse;
const aiResponse = data.choices?.[0]?.message?.content || null;
console.log(`[DEBUG] OpenRouter Response: ${aiResponse}`);
return aiResponse;
} catch (error) {
console.error('Error calling OpenRouter with history:', error);
if (error.response) {
console.error('Response status:', error.response.status);
console.error('Response data:', error.response.data);
}
return null;
}
}
/**
* Generate response using chatbot service with fallback to direct OpenRouter
*/
async generateResponseWithChatbot(
userMessage: string,
conversationHistory: any[],
systemMessage?: string,
job?: any,
candidateName?: string,
linkId?: string
): Promise<string | null> {
// Try chatbot service first
try {
const isHealthy = await this.chatbotService.isHealthy();
if (isHealthy) {
console.log(`[DEBUG] Using chatbot service for response generation`);
const response = await this.chatbotService.sendMessage({
message: userMessage,
conversationHistory,
systemMessage,
job,
candidateName,
linkId
});
if (response) {
return response;
}
}
} catch (error) {
console.error('[ERROR] Chatbot service failed, falling back to direct OpenRouter:', error);
}
// Fallback to direct OpenRouter
if (this.chatbotService.shouldUseFallback()) {
console.log(`[DEBUG] Falling back to direct OpenRouter`);
return await this.generateResponseWithHistory(userMessage, conversationHistory, systemMessage);
}
return null;
}
/**
* Initialize interview using chatbot service
*/
async initializeInterviewWithChatbot(
job: any,
candidateName: string,
linkId: string,
conversationHistory: any[] = []
): Promise<string | null> {
try {
const isHealthy = await this.chatbotService.isHealthy();
if (isHealthy) {
console.log(`[DEBUG] Using chatbot service for interview initialization`);
return await this.chatbotService.initializeInterview(job, candidateName, linkId, conversationHistory);
}
} catch (error) {
console.error('[ERROR] Chatbot service failed for interview initialization:', error);
}
// Fallback to direct OpenRouter
if (this.chatbotService.shouldUseFallback()) {
console.log(`[DEBUG] Falling back to direct OpenRouter for interview initialization`);
const systemMessage = this.buildInterviewSystemMessage(job, candidateName, conversationHistory);
return await this.generateResponse(`The candidate's name is ${candidateName}. Please start the interview.`, systemMessage);
}
return null;
}
/**
* End interview using chatbot service
*/
async endInterviewWithChatbot(linkId: string): Promise<boolean> {
try {
const isHealthy = await this.chatbotService.isHealthy();
if (isHealthy) {
console.log(`[DEBUG] Using chatbot service for interview end`);
return await this.chatbotService.endInterview(linkId);
}
} catch (error) {
console.error('[ERROR] Chatbot service failed for interview end:', error);
}
return false;
}
/**
* Build interview system message
*/
private buildInterviewSystemMessage(job: any, candidateName: string, conversationHistory: any[] = []): string {
const skills = job.skills_required ? job.skills_required.join(', ') : 'various technical skills';
const experience = job.experience_level.replace('_', ' ');
// Build context from conversation history (mandatory question answers)
const conversationContext = conversationHistory
.map(msg => `${msg.sender === 'candidate' ? 'Candidate' : 'Interviewer'}: ${msg.message}`)
.join('\n');
return `You are an AI interview agent conducting an interview for the position: ${job.title}
Job Description: ${job.description}
Requirements: ${job.requirements}
Required Skills: ${skills}
Experience Level: ${experience}
Location: ${job.location || 'Remote'}
${conversationContext ? `Previous conversation (mandatory questions answered):
${conversationContext}
Based on the candidate's answers to the mandatory questions above, you should now conduct a deeper interview.` : ''}
Your task is to:
1. Greet the candidate warmly and professionally
2. Introduce yourself as their evaluation agent
3. ${conversationContext ? 'Acknowledge their previous answers and build upon them' : 'Explain that you\'ll be conducting a comprehensive interview'}
4. Ask them to tell you about themselves and their interest in this role
5. Keep your response conversational and engaging
6. Don't ask multiple questions at once - start with one open-ended question
Respond in a friendly, professional tone. Keep it concise but welcoming.`;
}
}
+776
View File
@@ -0,0 +1,776 @@
import { pool } from '../config/database.js';
import { $log } from '@tsed/logger';
import bcrypt from 'bcryptjs';
import { randomUUID } from 'crypto';
export class AdminService {
// System Statistics
async getSystemStatistics() {
const connection = await pool.getConnection();
try {
// Get basic counts
const [userStats] = await connection.execute(`
SELECT
COUNT(*) as total_users,
SUM(CASE WHEN is_active = TRUE THEN 1 ELSE 0 END) as active_users
FROM users
WHERE deleted_at IS NULL
`);
const [jobStats] = await connection.execute(`
SELECT COUNT(*) as total_jobs
FROM jobs
WHERE deleted_at IS NULL
`);
const [interviewStats] = await connection.execute(`
SELECT COUNT(*) as total_interviews
FROM interviews
WHERE status = 'completed'
`);
const [tokenStats] = await connection.execute(`
SELECT
COALESCE(SUM(quantity), 0) as total_tokens_purchased,
COALESCE(SUM(tokens_used), 0) as total_tokens_used
FROM interview_tokens
`);
const [revenueStats] = await connection.execute(`
SELECT COALESCE(SUM(amount), 0) as total_revenue
FROM payment_records
WHERE status = 'paid'
`);
const userStatsData = Array.isArray(userStats) ? userStats[0] : userStats;
const jobStatsData = Array.isArray(jobStats) ? jobStats[0] : jobStats;
const interviewStatsData = Array.isArray(interviewStats) ? interviewStats[0] : interviewStats;
const tokenStatsData = Array.isArray(tokenStats) ? tokenStats[0] : tokenStats;
const revenueStatsData = Array.isArray(revenueStats) ? revenueStats[0] : revenueStats;
return {
total_users: userStatsData?.total_users || 0,
active_users: userStatsData?.active_users || 0,
total_jobs: jobStatsData?.total_jobs || 0,
total_interviews: interviewStatsData?.total_interviews || 0,
total_tokens_purchased: tokenStatsData?.total_tokens_purchased || 0,
total_tokens_used: tokenStatsData?.total_tokens_used || 0,
total_revenue: revenueStatsData?.total_revenue || 0,
generated_at: new Date().toISOString()
};
} catch (error) {
$log.error('Error getting system statistics:', error);
throw error;
} finally {
connection.release();
}
}
// User Management
async getAllUsers() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
id, email, first_name, last_name, role, company_name,
avatar_url, is_active, last_login_at, email_verified_at,
created_at, updated_at
FROM users
WHERE deleted_at IS NULL
ORDER BY created_at DESC
`);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting all users:', error);
throw error;
} finally {
connection.release();
}
}
async getUserById(id: string) {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
id, email, first_name, last_name, role, company_name,
avatar_url, is_active, last_login_at, email_verified_at,
created_at, updated_at
FROM users
WHERE id = ? AND deleted_at IS NULL
`, [id]);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0];
}
return null;
} catch (error) {
$log.error('Error getting user by ID:', error);
throw error;
} finally {
connection.release();
}
}
async updateUser(id: string, userData: any) {
const connection = await pool.getConnection();
try {
const updateFields = [];
const values = [];
if (userData.first_name) {
updateFields.push('first_name = ?');
values.push(userData.first_name);
}
if (userData.last_name) {
updateFields.push('last_name = ?');
values.push(userData.last_name);
}
if (userData.email) {
updateFields.push('email = ?');
values.push(userData.email);
}
if (userData.role) {
updateFields.push('role = ?');
values.push(userData.role);
}
if (userData.company_name !== undefined) {
updateFields.push('company_name = ?');
values.push(userData.company_name);
}
if (userData.avatar_url !== undefined) {
updateFields.push('avatar_url = ?');
values.push(userData.avatar_url);
}
if (userData.is_active !== undefined) {
updateFields.push('is_active = ?');
values.push(userData.is_active);
}
if (updateFields.length === 0) {
throw new Error('No fields to update');
}
updateFields.push('updated_at = NOW()');
values.push(id);
await connection.execute(
`UPDATE users SET ${updateFields.join(', ')} WHERE id = ? AND deleted_at IS NULL`,
values
);
return await this.getUserById(id);
} catch (error) {
$log.error('Error updating user:', error);
throw error;
} finally {
connection.release();
}
}
async toggleUserStatus(id: string) {
const connection = await pool.getConnection();
try {
// Get current status
const [rows] = await connection.execute(
'SELECT is_active FROM users WHERE id = ? AND deleted_at IS NULL',
[id]
);
if (Array.isArray(rows) && rows.length === 0) {
throw new Error('User not found');
}
const currentStatus = Array.isArray(rows) ? rows[0] : rows;
const newStatus = !currentStatus.is_active;
await connection.execute(
'UPDATE users SET is_active = ?, updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
[newStatus, id]
);
return { success: true, new_status: newStatus };
} catch (error) {
$log.error('Error toggling user status:', error);
throw error;
} finally {
connection.release();
}
}
async changeUserPassword(id: string, newPassword: string) {
const connection = await pool.getConnection();
try {
const password_hash = await bcrypt.hash(newPassword, 10);
await connection.execute(
'UPDATE users SET password_hash = ?, updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
[password_hash, id]
);
return { success: true };
} catch (error) {
$log.error('Error changing user password:', error);
throw error;
} finally {
connection.release();
}
}
async createUser(userData: any) {
const connection = await pool.getConnection();
try {
// Check if user already exists
const [existingUsers] = await connection.execute(
'SELECT id FROM users WHERE email = ? AND deleted_at IS NULL',
[userData.email]
);
if (Array.isArray(existingUsers) && existingUsers.length > 0) {
throw new Error('User with this email already exists');
}
// Hash password
const password_hash = await bcrypt.hash(userData.password, 10);
// Generate UUID for user ID
const userId = randomUUID();
// Insert user
await connection.execute(
`INSERT INTO users (id, email, password_hash, first_name, last_name, role, company_name, is_active, email_verified_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW(), NOW())`,
[
userId,
userData.email,
password_hash,
userData.first_name,
userData.last_name,
userData.role || 'recruiter',
userData.company_name || null,
true
]
);
// Initialize usage tracking
await connection.execute(
'INSERT INTO user_usage (user_id) VALUES (?)',
[userId]
);
return await this.getUserById(userId);
} catch (error) {
$log.error('Error creating user:', error);
throw error;
} finally {
connection.release();
}
}
// Job Management
async getAllJobs() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
j.*,
u.first_name,
u.last_name,
u.email,
u.company_name
FROM jobs j
LEFT JOIN users u ON j.user_id = u.id
WHERE j.deleted_at IS NULL
ORDER BY j.created_at DESC
`);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting all jobs:', error);
throw error;
} finally {
connection.release();
}
}
async getJobById(id: string) {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
j.*,
u.first_name,
u.last_name,
u.email,
u.company_name
FROM jobs j
LEFT JOIN users u ON j.user_id = u.id
WHERE j.id = ? AND j.deleted_at IS NULL
`, [id]);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0];
}
return null;
} catch (error) {
$log.error('Error getting job by ID:', error);
throw error;
} finally {
connection.release();
}
}
async updateJobStatus(id: string, status: string) {
const connection = await pool.getConnection();
try {
await connection.execute(
'UPDATE jobs SET status = ?, updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
[status, id]
);
return { success: true, new_status: status };
} catch (error) {
$log.error('Error updating job status:', error);
throw error;
} finally {
connection.release();
}
}
async updateJob(id: string, jobData: any) {
const connection = await pool.getConnection();
try {
const updateFields = [];
const values = [];
if (jobData.title) {
updateFields.push('title = ?');
values.push(jobData.title);
}
if (jobData.description) {
updateFields.push('description = ?');
values.push(jobData.description);
}
if (jobData.requirements) {
updateFields.push('requirements = ?');
values.push(jobData.requirements);
}
if (jobData.skills_required) {
updateFields.push('skills_required = ?');
values.push(JSON.stringify(jobData.skills_required));
}
if (jobData.location) {
updateFields.push('location = ?');
values.push(jobData.location);
}
if (jobData.employment_type) {
updateFields.push('employment_type = ?');
values.push(jobData.employment_type);
}
if (jobData.experience_level) {
updateFields.push('experience_level = ?');
values.push(jobData.experience_level);
}
if (jobData.salary_min !== undefined) {
updateFields.push('salary_min = ?');
values.push(jobData.salary_min);
}
if (jobData.salary_max !== undefined) {
updateFields.push('salary_max = ?');
values.push(jobData.salary_max);
}
if (jobData.currency) {
updateFields.push('currency = ?');
values.push(jobData.currency);
}
if (jobData.status) {
updateFields.push('status = ?');
values.push(jobData.status);
}
if (updateFields.length === 0) {
throw new Error('No fields to update');
}
updateFields.push('updated_at = NOW()');
values.push(id);
await connection.execute(
`UPDATE jobs SET ${updateFields.join(', ')} WHERE id = ? AND deleted_at IS NULL`,
values
);
return await this.getJobById(id);
} catch (error) {
$log.error('Error updating job:', error);
throw error;
} finally {
connection.release();
}
}
// Token Management
async getUserTokenSummaries() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
u.id as user_id,
u.first_name,
u.last_name,
u.email,
COALESCE(SUM(it.quantity), 0) as total_purchased,
COALESCE(SUM(it.tokens_used), 0) as total_used,
COALESCE(SUM(it.tokens_remaining), 0) as total_available,
CASE
WHEN SUM(it.quantity) > 0 THEN ROUND((SUM(it.tokens_used) / SUM(it.quantity)) * 100, 2)
ELSE 0
END as utilization_percentage
FROM users u
LEFT JOIN interview_tokens it ON u.id = it.user_id AND it.status = 'active'
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.first_name, u.last_name, u.email
ORDER BY u.created_at DESC
`);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting user token summaries:', error);
throw error;
} finally {
connection.release();
}
}
async addTokensToUser(tokenData: any) {
const connection = await pool.getConnection();
try {
const { user_id, quantity, price_per_token } = tokenData;
const total_price = quantity * price_per_token;
const tokenId = randomUUID();
// Create token record
await connection.execute(`
INSERT INTO interview_tokens (
id, user_id, token_type, quantity, price_per_token,
total_price, status, purchased_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'active', NOW(), NOW(), NOW())
`, [
tokenId,
user_id,
quantity === 1 ? 'single' : 'bulk',
quantity,
price_per_token,
total_price
]);
// No payment record needed for admin-granted tokens
// Update user usage
await connection.execute(`
INSERT INTO user_usage (user_id, tokens_purchased)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE tokens_purchased = tokens_purchased + ?
`, [user_id, quantity, quantity]);
return { success: true, token_id: tokenId };
} catch (error) {
$log.error('Error adding tokens to user:', error);
throw error;
} finally {
connection.release();
}
}
// Token Packages
async getTokenPackages() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT * FROM token_packages
ORDER BY created_at DESC
`);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting token packages:', error);
throw error;
} finally {
connection.release();
}
}
async createTokenPackage(packageData: any) {
const connection = await pool.getConnection();
try {
const packageId = randomUUID();
await connection.execute(`
INSERT INTO token_packages (
id, name, description, quantity, price_per_token,
total_price, discount_percentage, is_popular, is_active,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
`, [
packageId,
packageData.name,
packageData.description,
packageData.quantity,
packageData.price_per_token,
packageData.total_price,
packageData.discount_percentage || 0,
packageData.is_popular || false,
packageData.is_active !== false
]);
return { success: true, package_id: packageId };
} catch (error) {
$log.error('Error creating token package:', error);
throw error;
} finally {
connection.release();
}
}
async updateTokenPackage(id: string, packageData: any) {
const connection = await pool.getConnection();
try {
const updateFields = [];
const values = [];
if (packageData.name) {
updateFields.push('name = ?');
values.push(packageData.name);
}
if (packageData.description) {
updateFields.push('description = ?');
values.push(packageData.description);
}
if (packageData.quantity) {
updateFields.push('quantity = ?');
values.push(packageData.quantity);
}
if (packageData.price_per_token) {
updateFields.push('price_per_token = ?');
values.push(packageData.price_per_token);
}
if (packageData.total_price) {
updateFields.push('total_price = ?');
values.push(packageData.total_price);
}
if (packageData.discount_percentage !== undefined) {
updateFields.push('discount_percentage = ?');
values.push(packageData.discount_percentage);
}
if (packageData.is_popular !== undefined) {
updateFields.push('is_popular = ?');
values.push(packageData.is_popular);
}
if (packageData.is_active !== undefined) {
updateFields.push('is_active = ?');
values.push(packageData.is_active);
}
if (updateFields.length === 0) {
throw new Error('No fields to update');
}
updateFields.push('updated_at = NOW()');
values.push(id);
await connection.execute(
`UPDATE token_packages SET ${updateFields.join(', ')} WHERE id = ?`,
values
);
return { success: true };
} catch (error) {
$log.error('Error updating token package:', error);
throw error;
} finally {
connection.release();
}
}
async toggleTokenPackageStatus(id: string) {
const connection = await pool.getConnection();
try {
// Get current status
const [rows] = await connection.execute(
'SELECT is_active FROM token_packages WHERE id = ?',
[id]
);
if (Array.isArray(rows) && rows.length === 0) {
throw new Error('Token package not found');
}
const currentStatus = Array.isArray(rows) ? rows[0] : rows;
const newStatus = !currentStatus.is_active;
await connection.execute(
'UPDATE token_packages SET is_active = ?, updated_at = NOW() WHERE id = ?',
[newStatus, id]
);
return { success: true, new_status: newStatus };
} catch (error) {
$log.error('Error toggling token package status:', error);
throw error;
} finally {
connection.release();
}
}
async deleteTokenPackage(id: string) {
const connection = await pool.getConnection();
try {
await connection.execute(
'DELETE FROM token_packages WHERE id = ?',
[id]
);
return { success: true };
} catch (error) {
$log.error('Error deleting token package:', error);
throw error;
} finally {
connection.release();
}
}
// Interview Management
async getAllInterviews() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
i.*,
u.first_name,
u.last_name,
u.email,
j.title as job_title
FROM interviews i
LEFT JOIN users u ON i.user_id = u.id
LEFT JOIN jobs j ON i.job_id = j.id
ORDER BY i.created_at DESC
`);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting all interviews:', error);
throw error;
} finally {
connection.release();
}
}
async getInterviewById(id: string) {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
i.*,
u.first_name,
u.last_name,
u.email,
j.title as job_title
FROM interviews i
LEFT JOIN users u ON i.user_id = u.id
LEFT JOIN jobs j ON i.job_id = j.id
WHERE i.id = ?
`, [id]);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0];
}
return null;
} catch (error) {
$log.error('Error getting interview by ID:', error);
throw error;
} finally {
connection.release();
}
}
// Payment Records
async getPaymentRecords() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
pr.*,
u.first_name,
u.last_name,
u.email,
tp.name as package_name
FROM payment_records pr
LEFT JOIN users u ON pr.user_id = u.id
LEFT JOIN token_packages tp ON pr.token_package_id = tp.id
ORDER BY pr.created_at DESC
`);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting payment records:', error);
throw error;
} finally {
connection.release();
}
}
async getPaymentById(id: string) {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
pr.*,
u.first_name,
u.last_name,
u.email,
tp.name as package_name
FROM payment_records pr
LEFT JOIN users u ON pr.user_id = u.id
LEFT JOIN token_packages tp ON pr.token_package_id = tp.id
WHERE pr.id = ?
`, [id]);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0];
}
return null;
} catch (error) {
$log.error('Error getting payment by ID:', error);
throw error;
} finally {
connection.release();
}
}
}
+170
View File
@@ -0,0 +1,170 @@
import axios, { AxiosInstance, AxiosResponse } from 'axios';
export interface ChatbotRequest {
message: string;
conversationHistory?: any[];
job?: any;
candidateName?: string;
linkId?: string;
systemMessage?: string;
}
export interface ChatbotResponse {
ok: boolean;
reply?: string;
error?: string;
}
export interface ChatbotHealthResponse {
status: string;
timestamp: string;
}
export class ChatbotService {
private client: AxiosInstance;
private baseUrl: string;
private timeout: number;
private fallbackEnabled: boolean;
constructor() {
this.baseUrl = process.env.CHATBOT_SERVICE_URL || 'http://chatbot:80';
this.timeout = parseInt(process.env.CHATBOT_SERVICE_TIMEOUT || '30000');
this.fallbackEnabled = process.env.CHATBOT_FALLBACK_ENABLED === 'true';
this.client = axios.create({
baseURL: this.baseUrl,
timeout: this.timeout,
headers: {
'Content-Type': 'application/json',
},
});
console.log(`[DEBUG] ChatbotService initialized:`);
console.log(`[DEBUG] - Base URL: ${this.baseUrl}`);
console.log(`[DEBUG] - Timeout: ${this.timeout}ms`);
console.log(`[DEBUG] - Fallback Enabled: ${this.fallbackEnabled}`);
}
/**
* Check if chatbot service is healthy
*/
async isHealthy(): Promise<boolean> {
try {
const response = await this.client.get('/api/health');
return response.status === 200;
} catch (error) {
console.error('[ERROR] Chatbot service health check failed:', error);
return false;
}
}
/**
* Send a chat message to the chatbot service
*/
async sendMessage(request: ChatbotRequest): Promise<string | null> {
try {
console.log(`[DEBUG] Sending message to chatbot service: ${request.message.substring(0, 100)}...`);
const response: AxiosResponse<ChatbotResponse> = await this.client.post('/api/chat', {
message: request.message,
conversationHistory: request.conversationHistory,
job: request.job,
candidateName: request.candidateName,
linkId: request.linkId,
systemMessage: request.systemMessage
});
if (response.data.ok && response.data.reply) {
console.log(`[DEBUG] Chatbot service response received: ${response.data.reply.substring(0, 100)}...`);
return response.data.reply;
} else {
console.error('[ERROR] Chatbot service returned error:', response.data.error);
return null;
}
} catch (error) {
console.error('[ERROR] Chatbot service request failed:', error);
if (error.response) {
console.error('[ERROR] Response status:', error.response.status);
console.error('[ERROR] Response data:', error.response.data);
}
return null;
}
}
/**
* Initialize an interview with the chatbot service
*/
async initializeInterview(job: any, candidateName: string, linkId: string, conversationHistory: any[] = []): Promise<string | null> {
try {
console.log(`[DEBUG] Initializing interview with chatbot service for ${candidateName}`);
const response: AxiosResponse<ChatbotResponse> = await this.client.post('/api/interview/start', {
job,
candidateName,
linkId,
conversationHistory
});
if (response.data.ok && response.data.reply) {
console.log(`[DEBUG] Interview initialized successfully`);
return response.data.reply;
} else {
console.error('[ERROR] Failed to initialize interview:', response.data.error);
return null;
}
} catch (error) {
console.error('[ERROR] Interview initialization failed:', error);
return null;
}
}
/**
* End an interview with the chatbot service
*/
async endInterview(linkId: string): Promise<boolean> {
try {
console.log(`[DEBUG] Ending interview with chatbot service for linkId: ${linkId}`);
const response: AxiosResponse<ChatbotResponse> = await this.client.post('/api/interview/end', {
linkId
});
if (response.data.ok) {
console.log(`[DEBUG] Interview ended successfully`);
return true;
} else {
console.error('[ERROR] Failed to end interview:', response.data.error);
return false;
}
} catch (error) {
console.error('[ERROR] Interview end failed:', error);
return false;
}
}
/**
* Get interview status from chatbot service
*/
async getInterviewStatus(linkId: string): Promise<any | null> {
try {
const response: AxiosResponse<ChatbotResponse> = await this.client.get(`/api/interview/status/${linkId}`);
if (response.data.ok) {
return response.data;
} else {
console.error('[ERROR] Failed to get interview status:', response.data.error);
return null;
}
} catch (error) {
console.error('[ERROR] Get interview status failed:', error);
return null;
}
}
/**
* Check if fallback to direct AI service should be used
*/
shouldUseFallback(): boolean {
return this.fallbackEnabled;
}
}
File diff suppressed because it is too large Load Diff
+443
View File
@@ -0,0 +1,443 @@
import { pool } from '../config/database.js';
import { $log } from '@tsed/logger';
import { randomUUID } from 'crypto';
export interface InterviewToken {
id: string;
user_id: string;
token_type: 'single' | 'bulk';
quantity: number;
price_per_token: number;
total_price: number;
tokens_used: number;
tokens_remaining: number;
status: 'active' | 'exhausted' | 'expired';
expires_at?: string;
purchased_at: string;
created_at: string;
updated_at: string;
}
export interface TokenPackage {
id: string;
name: string;
description: string;
quantity: number;
price_per_token: number;
total_price: number;
discount_percentage: number;
is_popular: boolean;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateTokenPackageRequest {
name: string;
description: string;
quantity: number;
price_per_token: number;
total_price: number;
discount_percentage?: number;
is_popular?: boolean;
is_active?: boolean;
}
export interface UpdateTokenPackageRequest {
name?: string;
description?: string;
quantity?: number;
price_per_token?: number;
total_price?: number;
discount_percentage?: number;
is_popular?: boolean;
is_active?: boolean;
}
export class TokenService {
// Token Packages
async getTokenPackages(): Promise<TokenPackage[]> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM token_packages ORDER BY created_at DESC'
);
return Array.isArray(rows) ? rows as TokenPackage[] : [];
} catch (error) {
$log.error('Error getting token packages:', error);
throw error;
} finally {
connection.release();
}
}
async getTokenPackageById(id: string): Promise<TokenPackage | null> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM token_packages WHERE id = ?',
[id]
);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0] as TokenPackage;
}
return null;
} catch (error) {
$log.error('Error getting token package by ID:', error);
throw error;
} finally {
connection.release();
}
}
async createTokenPackage(packageData: CreateTokenPackageRequest): Promise<TokenPackage> {
const connection = await pool.getConnection();
try {
const packageId = randomUUID();
await connection.execute(`
INSERT INTO token_packages (
id, name, description, quantity, price_per_token,
total_price, discount_percentage, is_popular, is_active,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
`, [
packageId,
packageData.name,
packageData.description,
packageData.quantity,
packageData.price_per_token,
packageData.total_price,
packageData.discount_percentage || 0,
packageData.is_popular || false,
packageData.is_active !== false
]);
return await this.getTokenPackageById(packageId) as TokenPackage;
} catch (error) {
$log.error('Error creating token package:', error);
throw error;
} finally {
connection.release();
}
}
async updateTokenPackage(id: string, packageData: UpdateTokenPackageRequest): Promise<TokenPackage | null> {
const connection = await pool.getConnection();
try {
const updateFields = [];
const values = [];
if (packageData.name) {
updateFields.push('name = ?');
values.push(packageData.name);
}
if (packageData.description) {
updateFields.push('description = ?');
values.push(packageData.description);
}
if (packageData.quantity) {
updateFields.push('quantity = ?');
values.push(packageData.quantity);
}
if (packageData.price_per_token) {
updateFields.push('price_per_token = ?');
values.push(packageData.price_per_token);
}
if (packageData.total_price) {
updateFields.push('total_price = ?');
values.push(packageData.total_price);
}
if (packageData.discount_percentage !== undefined) {
updateFields.push('discount_percentage = ?');
values.push(packageData.discount_percentage);
}
if (packageData.is_popular !== undefined) {
updateFields.push('is_popular = ?');
values.push(packageData.is_popular);
}
if (packageData.is_active !== undefined) {
updateFields.push('is_active = ?');
values.push(packageData.is_active);
}
if (updateFields.length === 0) {
throw new Error('No fields to update');
}
updateFields.push('updated_at = NOW()');
values.push(id);
await connection.execute(
`UPDATE token_packages SET ${updateFields.join(', ')} WHERE id = ?`,
values
);
return await this.getTokenPackageById(id);
} catch (error) {
$log.error('Error updating token package:', error);
throw error;
} finally {
connection.release();
}
}
async deleteTokenPackage(id: string): Promise<void> {
const connection = await pool.getConnection();
try {
await connection.execute(
'DELETE FROM token_packages WHERE id = ?',
[id]
);
} catch (error) {
$log.error('Error deleting token package:', error);
throw error;
} finally {
connection.release();
}
}
async toggleTokenPackageStatus(id: string): Promise<{ success: boolean; new_status: boolean }> {
const connection = await pool.getConnection();
try {
// Get current status
const [rows] = await connection.execute(
'SELECT is_active FROM token_packages WHERE id = ?',
[id]
);
if (Array.isArray(rows) && rows.length === 0) {
throw new Error('Token package not found');
}
const currentStatus = Array.isArray(rows) ? rows[0] : rows;
const newStatus = !currentStatus.is_active;
await connection.execute(
'UPDATE token_packages SET is_active = ?, updated_at = NOW() WHERE id = ?',
[newStatus, id]
);
return { success: true, new_status: newStatus };
} catch (error) {
$log.error('Error toggling token package status:', error);
throw error;
} finally {
connection.release();
}
}
// Interview Tokens
async getTokensByUserId(userId: string): Promise<InterviewToken[]> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM interview_tokens WHERE user_id = ? ORDER BY created_at DESC',
[userId]
);
return Array.isArray(rows) ? rows as InterviewToken[] : [];
} catch (error) {
$log.error('Error getting tokens by user ID:', error);
throw error;
} finally {
connection.release();
}
}
async getTokenById(id: string): Promise<InterviewToken | null> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM interview_tokens WHERE id = ?',
[id]
);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0] as InterviewToken;
}
return null;
} catch (error) {
$log.error('Error getting token by ID:', error);
throw error;
} finally {
connection.release();
}
}
async addTokensToUser(userId: string, quantity: number, pricePerToken: number): Promise<InterviewToken> {
const connection = await pool.getConnection();
try {
const totalPrice = quantity * pricePerToken;
const tokenId = randomUUID();
// Create token record
await connection.execute(`
INSERT INTO interview_tokens (
id, user_id, token_type, quantity, price_per_token,
total_price, status, purchased_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'active', NOW(), NOW(), NOW())
`, [
tokenId,
userId,
quantity === 1 ? 'single' : 'bulk',
quantity,
pricePerToken,
totalPrice
]);
// No payment record needed for admin-granted tokens
// Update user usage
await connection.execute(`
INSERT INTO user_usage (user_id, tokens_purchased)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE tokens_purchased = tokens_purchased + ?
`, [userId, quantity, quantity]);
return await this.getTokenById(tokenId) as InterviewToken;
} catch (error) {
$log.error('Error adding tokens to user:', error);
throw error;
} finally {
connection.release();
}
}
async useToken(tokenId: string): Promise<boolean> {
const connection = await pool.getConnection();
try {
// Get current token status
const token = await this.getTokenById(tokenId);
if (!token || token.status !== 'active') {
return false;
}
// Check if token has remaining uses
if (token.tokens_remaining <= 0) {
return false;
}
// Update token usage
const newUsedCount = token.tokens_used + 1;
const newStatus = newUsedCount >= token.quantity ? 'exhausted' : 'active';
await connection.execute(`
UPDATE interview_tokens
SET tokens_used = ?, status = ?, updated_at = NOW()
WHERE id = ?
`, [newUsedCount, newStatus, tokenId]);
// Update user usage
await connection.execute(`
INSERT INTO user_usage (user_id, tokens_used)
VALUES (?, 1)
ON DUPLICATE KEY UPDATE tokens_used = tokens_used + 1
`, [token.user_id]);
return true;
} catch (error) {
$log.error('Error using token:', error);
throw error;
} finally {
connection.release();
}
}
async getUserTokenSummary(userId: string): Promise<{
total_purchased: number;
total_used: number;
total_available: number;
utilization_percentage: number;
}> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
COALESCE(SUM(quantity), 0) as total_purchased,
COALESCE(SUM(tokens_used), 0) as total_used,
COALESCE(SUM(tokens_remaining), 0) as total_available
FROM interview_tokens
WHERE user_id = ? AND status = 'active'
`, [userId]);
const data = Array.isArray(rows) ? rows[0] : rows;
const totalPurchased = data?.total_purchased || 0;
const totalUsed = data?.total_used || 0;
const totalAvailable = data?.total_available || 0;
const utilizationPercentage = totalPurchased > 0
? Math.round((totalUsed / totalPurchased) * 100)
: 0;
return {
total_purchased: totalPurchased,
total_used: totalUsed,
total_available: totalAvailable,
utilization_percentage: utilizationPercentage
};
} catch (error) {
$log.error('Error getting user token summary:', error);
throw error;
} finally {
connection.release();
}
}
async getAllUserTokenSummaries(): Promise<Array<{
user_id: string;
first_name: string;
last_name: string;
email: string;
total_purchased: number;
total_used: number;
total_available: number;
utilization_percentage: number;
}>> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
u.id as user_id,
u.first_name,
u.last_name,
u.email,
COALESCE(SUM(it.quantity), 0) as total_purchased,
COALESCE(SUM(it.tokens_used), 0) as total_used,
COALESCE(SUM(it.tokens_remaining), 0) as total_available,
CASE
WHEN SUM(it.quantity) > 0 THEN ROUND((SUM(it.tokens_used) / SUM(it.quantity)) * 100, 2)
ELSE 0
END as utilization_percentage
FROM users u
LEFT JOIN interview_tokens it ON u.id = it.user_id AND it.status = 'active'
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.first_name, u.last_name, u.email
ORDER BY u.created_at DESC
`);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting all user token summaries:', error);
throw error;
} finally {
connection.release();
}
}
}
+223
View File
@@ -0,0 +1,223 @@
import { pool } from '../config/database.js';
import { User, CreateUserRequest, UpdateUserRequest, UserResponse } from '../models/User.js';
import { $log } from '@tsed/logger';
import bcrypt from 'bcryptjs';
import { randomUUID } from 'crypto';
export class UserService {
async createUser(userData: CreateUserRequest): Promise<UserResponse> {
const connection = await pool.getConnection();
try {
// Check if user already exists
const [existingUsers] = await connection.execute(
'SELECT id FROM users WHERE email = ? AND deleted_at IS NULL',
[userData.email]
);
if (Array.isArray(existingUsers) && existingUsers.length > 0) {
throw new Error('User with this email already exists');
}
// Hash password
const password_hash = await bcrypt.hash(userData.password, 10);
// Generate UUID for user ID
const userId = randomUUID();
// Insert user
const [result] = await connection.execute(
`INSERT INTO users (id, email, password_hash, first_name, last_name, role, company_name, is_active, email_verified_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW(), NOW())`,
[
userId,
userData.email,
password_hash,
userData.first_name,
userData.last_name,
userData.role || 'recruiter',
userData.company_name || null,
true
]
);
// Get the created user
const user = await this.getUserById(userId);
if (!user) {
throw new Error('Failed to create user');
}
return this.mapUserToResponse(user);
} catch (error) {
$log.error('Error creating user:', error);
throw error;
} finally {
connection.release();
}
}
async getUserByEmail(email: string): Promise<User | null> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM users WHERE email = ? AND deleted_at IS NULL',
[email]
);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0] as User;
}
return null;
} catch (error) {
$log.error('Error getting user by email:', error);
throw error;
} finally {
connection.release();
}
}
async getUserById(id: string): Promise<User | null> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM users WHERE id = ? AND deleted_at IS NULL',
[id]
);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0] as User;
}
return null;
} catch (error) {
$log.error('Error getting user by ID:', error);
throw error;
} finally {
connection.release();
}
}
async updateUser(id: string, userData: UpdateUserRequest): Promise<UserResponse | null> {
const connection = await pool.getConnection();
try {
const updateFields = [];
const values = [];
if (userData.first_name) {
updateFields.push('first_name = ?');
values.push(userData.first_name);
}
if (userData.last_name) {
updateFields.push('last_name = ?');
values.push(userData.last_name);
}
if (userData.company_name !== undefined) {
updateFields.push('company_name = ?');
values.push(userData.company_name);
}
if (userData.avatar_url !== undefined) {
updateFields.push('avatar_url = ?');
values.push(userData.avatar_url);
}
if (userData.is_active !== undefined) {
updateFields.push('is_active = ?');
values.push(userData.is_active);
}
if (updateFields.length === 0) {
throw new Error('No fields to update');
}
updateFields.push('updated_at = NOW()');
values.push(id);
await connection.execute(
`UPDATE users SET ${updateFields.join(', ')} WHERE id = ? AND deleted_at IS NULL`,
values
);
const user = await this.getUserById(id);
return user ? this.mapUserToResponse(user) : null;
} catch (error) {
$log.error('Error updating user:', error);
throw error;
} finally {
connection.release();
}
}
async updateLastLogin(id: string): Promise<void> {
const connection = await pool.getConnection();
try {
await connection.execute(
'UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
[id]
);
} catch (error) {
$log.error('Error updating last login:', error);
throw error;
} finally {
connection.release();
}
}
async verifyPassword(user: User, password: string): Promise<boolean> {
return await bcrypt.compare(password, user.password_hash);
}
async changePassword(id: string, newPassword: string): Promise<void> {
const connection = await pool.getConnection();
try {
const password_hash = await bcrypt.hash(newPassword, 10);
await connection.execute(
'UPDATE users SET password_hash = ?, updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
[password_hash, id]
);
} catch (error) {
$log.error('Error changing password:', error);
throw error;
} finally {
connection.release();
}
}
async softDeleteUser(id: string): Promise<void> {
const connection = await pool.getConnection();
try {
await connection.execute(
'UPDATE users SET deleted_at = NOW(), is_active = FALSE, updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
[id]
);
} catch (error) {
$log.error('Error soft deleting user:', error);
throw error;
} finally {
connection.release();
}
}
private mapUserToResponse(user: User): UserResponse {
return {
id: user.id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
company_name: user.company_name,
avatar_url: user.avatar_url,
is_active: user.is_active,
last_login_at: user.last_login_at,
email_verified_at: user.email_verified_at,
created_at: user.created_at,
updated_at: user.updated_at
};
}
}
+171
View File
@@ -0,0 +1,171 @@
// Admin-specific types
export interface SystemStatistics {
total_users: number;
active_users: number;
total_jobs: number;
total_interviews: number;
total_tokens_purchased: number;
total_tokens_used: number;
total_revenue: number;
generated_at: string;
}
export interface UserWithStats {
id: string;
email: string;
first_name: string;
last_name: string;
role: 'admin' | 'recruiter';
company_name?: string;
avatar_url?: string;
is_active: boolean;
last_login_at?: string;
email_verified_at?: string;
created_at: string;
updated_at: string;
}
export interface JobWithUser {
id: string;
user_id: string;
title: string;
description: string;
requirements: string;
skills_required: string[];
location: string;
employment_type: string;
experience_level: string;
salary_min?: number;
salary_max?: number;
currency: string;
status: string;
evaluation_criteria: any;
interview_questions: any;
application_deadline?: string;
created_at: string;
updated_at: string;
first_name?: string;
last_name?: string;
email?: string;
company_name?: string;
}
export interface UserTokenSummary {
user_id: string;
first_name: string;
last_name: string;
email: string;
total_purchased: number;
total_used: number;
total_available: number;
utilization_percentage: number;
}
export interface TokenPackage {
id: string;
name: string;
description: string;
quantity: number;
price_per_token: number;
total_price: number;
discount_percentage: number;
is_popular: boolean;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface AddTokensRequest {
user_id: string;
quantity: number;
price_per_token: number;
total_price: number;
}
export interface CreateUserRequest {
email: string;
password: string;
first_name: string;
last_name: string;
role: 'admin' | 'recruiter';
company_name?: string;
}
export interface UpdateUserRequest {
first_name?: string;
last_name?: string;
email?: string;
role?: 'admin' | 'recruiter';
company_name?: string;
avatar_url?: string;
is_active?: boolean;
}
export interface CreateTokenPackageRequest {
name: string;
description: string;
quantity: number;
price_per_token: number;
total_price: number;
discount_percentage?: number;
is_popular?: boolean;
is_active?: boolean;
}
export interface UpdateTokenPackageRequest {
name?: string;
description?: string;
quantity?: number;
price_per_token?: number;
total_price?: number;
discount_percentage?: number;
is_popular?: boolean;
is_active?: boolean;
}
export interface InterviewWithDetails {
id: string;
user_id: string;
candidate_id: string;
job_id: string;
token: string;
status: string;
started_at?: string;
completed_at?: string;
duration_minutes: number;
ai_questions: any;
candidate_responses: any;
ai_evaluation: any;
overall_score?: number;
technical_score?: number;
communication_score?: number;
culture_fit_score?: number;
ai_feedback?: string;
created_at: string;
updated_at: string;
first_name?: string;
last_name?: string;
email?: string;
job_title?: string;
}
export interface PaymentRecord {
id: string;
user_id: string;
interview_token_id?: string;
token_package_id?: string;
amount: number;
currency: string;
status: string;
payment_method?: string;
payment_reference?: string;
invoice_url?: string;
paid_at?: string;
created_at: string;
updated_at: string;
first_name?: string;
last_name?: string;
email?: string;
package_name?: string;
}
+51
View File
@@ -0,0 +1,51 @@
// Authentication types that will be preserved in JavaScript compilation
export const LoginRequestSchema = {
email: String,
password: String
};
export const RegisterRequestSchema = {
email: String,
password: String,
first_name: String,
last_name: String,
company_name: String
};
export const CreateUserRequestSchema = {
email: String,
password: String,
first_name: String,
last_name: String,
company_name: String,
role: String
};
export const UpdateUserRequestSchema = {
first_name: String,
last_name: String,
company_name: String,
avatar_url: String,
is_active: Boolean
};
export const UserResponseSchema = {
id: String,
email: String,
first_name: String,
last_name: String,
role: String,
company_name: String,
avatar_url: String,
is_active: Boolean,
last_login_at: Date,
email_verified_at: Date,
created_at: Date,
updated_at: Date
};
export const LoginResponseSchema = {
token: String,
user: UserResponseSchema
};