|
import { pool } from '../config/database.js';
|
|
import { $log } from '@tsed/logger';
|
|
import { randomUUID } from 'crypto';
|
|
|
|
export interface Job {
|
|
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: string[];
|
|
interview_style?: 'technical' | 'personal' | 'balanced';
|
|
application_deadline?: string;
|
|
icon?: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
deleted_at?: string;
|
|
}
|
|
|
|
export interface CreateJobRequest {
|
|
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;
|
|
evaluation_criteria?: any;
|
|
interview_questions?: any;
|
|
application_deadline?: string;
|
|
icon?: string;
|
|
}
|
|
|
|
export interface UpdateJobRequest {
|
|
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?: string[];
|
|
interview_style?: 'technical' | 'personal' | 'balanced';
|
|
application_deadline?: string;
|
|
}
|
|
|
|
export interface JobLink {
|
|
id: string;
|
|
job_id: string;
|
|
url_slug: string;
|
|
tokens_available: number;
|
|
tokens_used?: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export class JobService {
|
|
// Helper function to safely parse JSON fields
|
|
private parseJsonField(field: any, defaultValue: any) {
|
|
try {
|
|
if (typeof field === 'string') {
|
|
return JSON.parse(field);
|
|
}
|
|
return field || defaultValue;
|
|
} catch (error) {
|
|
console.warn(`Failed to parse JSON field: ${field}, using default value`);
|
|
return defaultValue;
|
|
}
|
|
}
|
|
|
|
async updateJob(jobId: string, updates: UpdateJobRequest): Promise<Job | null> {
|
|
const connection = await pool.getConnection();
|
|
try {
|
|
console.log('JobService.updateJob called with:', { jobId, updates });
|
|
|
|
// Build dynamic SET clause
|
|
const fields: string[] = [];
|
|
const values: any[] = [];
|
|
|
|
if (updates.title !== undefined) { fields.push('title = ?'); values.push(updates.title); }
|
|
if (updates.description !== undefined) { fields.push('description = ?'); values.push(updates.description); }
|
|
if (updates.requirements !== undefined) { fields.push('requirements = ?'); values.push(updates.requirements); }
|
|
if (updates.skills_required !== undefined) {
|
|
const skillsArray = Array.isArray(updates.skills_required) ? updates.skills_required : [];
|
|
fields.push('skills_required = ?'); values.push(JSON.stringify(skillsArray));
|
|
}
|
|
if (updates.location !== undefined) { fields.push('location = ?'); values.push(updates.location); }
|
|
if (updates.employment_type !== undefined) { fields.push('employment_type = ?'); values.push(updates.employment_type); }
|
|
if (updates.experience_level !== undefined) { fields.push('experience_level = ?'); values.push(updates.experience_level); }
|
|
if (updates.salary_min !== undefined) { fields.push('salary_min = ?'); values.push(updates.salary_min); }
|
|
if (updates.salary_max !== undefined) { fields.push('salary_max = ?'); values.push(updates.salary_max); }
|
|
if (updates.currency !== undefined) { fields.push('currency = ?'); values.push(updates.currency); }
|
|
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
|
|
if (updates.evaluation_criteria !== undefined) { fields.push('evaluation_criteria = ?'); values.push(JSON.stringify(updates.evaluation_criteria ?? null)); }
|
|
if (updates.interview_questions !== undefined) {
|
|
const questionsArray = Array.isArray(updates.interview_questions) ? updates.interview_questions : [];
|
|
fields.push('interview_questions = ?');
|
|
values.push(JSON.stringify(questionsArray));
|
|
}
|
|
if (updates.interview_style !== undefined) {
|
|
console.log('Adding interview_style field:', updates.interview_style);
|
|
fields.push('interview_style = ?');
|
|
values.push(updates.interview_style);
|
|
}
|
|
if (updates.application_deadline !== undefined) { fields.push('application_deadline = ?'); values.push(updates.application_deadline || null); }
|
|
|
|
console.log('Fields to update:', fields);
|
|
console.log('Values:', values);
|
|
|
|
if (fields.length === 0) {
|
|
console.log('No fields to update, returning existing job');
|
|
const job = await this.getJobById(jobId);
|
|
return job as Job | null;
|
|
}
|
|
|
|
fields.push('updated_at = NOW()');
|
|
|
|
const sql = `UPDATE jobs SET ${fields.join(', ')} WHERE id = ?`;
|
|
values.push(jobId);
|
|
await connection.execute(sql, values);
|
|
|
|
const updated = await this.getJobById(jobId);
|
|
return updated as Job | null;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async updateJobStatus(jobId: string, status: string): Promise<Job | null> {
|
|
const connection = await pool.getConnection();
|
|
try {
|
|
await connection.execute(
|
|
'UPDATE jobs SET status = ?, updated_at = NOW() WHERE id = ?',
|
|
[status, jobId]
|
|
);
|
|
const updated = await this.getJobById(jobId);
|
|
return updated as Job | null;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
// Helper function to safely get tokens_used (backward compatibility)
|
|
private getTokensUsed(link: any): number {
|
|
return link.tokens_used || 0;
|
|
}
|
|
|
|
async createJob(userId: string, jobData: CreateJobRequest): Promise<Job> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
console.log('JobService: Creating job for user:', userId);
|
|
console.log('JobService: Job data:', jobData);
|
|
|
|
const jobId = randomUUID();
|
|
|
|
// Ensure skills_required is always an array
|
|
const skillsArray = Array.isArray(jobData.skills_required) ? jobData.skills_required : [];
|
|
|
|
const result = await connection.execute(`
|
|
INSERT INTO jobs (
|
|
id, user_id, title, description, requirements, skills_required,
|
|
location, employment_type, experience_level, salary_min, salary_max,
|
|
currency, status, evaluation_criteria, interview_questions,
|
|
application_deadline, icon, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
|
`, [
|
|
jobId,
|
|
userId,
|
|
jobData.title,
|
|
jobData.description,
|
|
jobData.requirements,
|
|
JSON.stringify(skillsArray),
|
|
jobData.location || null,
|
|
jobData.employment_type || 'full_time',
|
|
jobData.experience_level || 'mid',
|
|
jobData.salary_min || null,
|
|
jobData.salary_max || null,
|
|
jobData.currency || 'USD',
|
|
'draft',
|
|
JSON.stringify(jobData.evaluation_criteria || {}),
|
|
JSON.stringify(jobData.interview_questions || {}),
|
|
jobData.application_deadline || null,
|
|
jobData.icon || 'briefcase'
|
|
]);
|
|
|
|
console.log('JobService: Job inserted with ID:', jobId);
|
|
|
|
const createdJob = await this.getJobById(jobId);
|
|
if (!createdJob) {
|
|
throw new Error('Failed to retrieve created job');
|
|
}
|
|
|
|
console.log('JobService: Job created successfully:', createdJob.id);
|
|
return createdJob;
|
|
} catch (error) {
|
|
console.error('JobService: Error creating job:', error);
|
|
$log.error('Error creating job:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async getJobById(id: string): Promise<Job | null> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
const [rows] = await connection.execute(
|
|
'SELECT * FROM jobs WHERE id = ? AND deleted_at IS NULL',
|
|
[id]
|
|
);
|
|
|
|
if (Array.isArray(rows) && rows.length > 0) {
|
|
const job = rows[0] as any;
|
|
console.log('JobService: Raw job data from DB:', {
|
|
id: job.id,
|
|
skills_required: job.skills_required,
|
|
skills_type: typeof job.skills_required,
|
|
evaluation_criteria: job.evaluation_criteria,
|
|
interview_questions: job.interview_questions
|
|
});
|
|
|
|
return {
|
|
...job,
|
|
skills_required: this.parseJsonField(job.skills_required, []),
|
|
evaluation_criteria: this.parseJsonField(job.evaluation_criteria, {}),
|
|
interview_questions: this.parseJsonField(job.interview_questions, []),
|
|
interview_style: job.interview_style || 'balanced'
|
|
};
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
$log.error('Error getting job by ID:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async getJobsByUserId(userId: string): Promise<Job[]> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
const [rows] = await connection.execute(
|
|
`SELECT
|
|
j.*,
|
|
DATEDIFF(NOW(), j.created_at) AS running_days,
|
|
COUNT(DISTINCT i.id) AS total_interviews,
|
|
SUM(CASE WHEN i.status = 'completed' THEN 1 ELSE 0 END) AS interviews_completed,
|
|
COUNT(DISTINCT c.id) AS applications
|
|
FROM jobs j
|
|
LEFT JOIN interviews i ON i.job_id = j.id
|
|
LEFT JOIN candidates c ON c.job_id = j.id
|
|
WHERE j.user_id = ? AND j.deleted_at IS NULL
|
|
GROUP BY j.id
|
|
ORDER BY j.created_at DESC`,
|
|
[userId]
|
|
);
|
|
|
|
if (Array.isArray(rows)) {
|
|
return rows.map((job: any) => {
|
|
const parsed = {
|
|
...job,
|
|
skills_required: this.parseJsonField(job.skills_required, []),
|
|
evaluation_criteria: this.parseJsonField(job.evaluation_criteria, {}),
|
|
interview_questions: this.parseJsonField(job.interview_questions, []),
|
|
interview_style: job.interview_style || 'balanced'
|
|
} as any;
|
|
// Derive available_interviews if not provided
|
|
parsed.total_interviews = Number(job.total_interviews || 0);
|
|
parsed.interviews_completed = Number(job.interviews_completed || 0);
|
|
parsed.available_interviews = Math.max(
|
|
0,
|
|
parsed.total_interviews - parsed.interviews_completed
|
|
);
|
|
parsed.applications = Number(job.applications || 0);
|
|
parsed.running_days = Number(job.running_days || 0);
|
|
return parsed;
|
|
});
|
|
}
|
|
|
|
return [];
|
|
} catch (error) {
|
|
$log.error('Error getting jobs by user ID (with metrics). Falling back to basic query:', error);
|
|
// Fallback: simple query without metrics so frontend still works
|
|
const [rows] = await connection.execute(
|
|
'SELECT * FROM jobs WHERE user_id = ? AND deleted_at IS NULL ORDER BY created_at DESC',
|
|
[userId]
|
|
);
|
|
const jobs = Array.isArray(rows) ? rows : [];
|
|
return jobs.map((job: any) => ({
|
|
...job,
|
|
skills_required: this.parseJsonField(job.skills_required, []),
|
|
evaluation_criteria: this.parseJsonField(job.evaluation_criteria, {}),
|
|
interview_questions: this.parseJsonField(job.interview_questions, []),
|
|
interview_style: job.interview_style || 'balanced',
|
|
total_interviews: 0,
|
|
interviews_completed: 0,
|
|
available_interviews: 0,
|
|
applications: 0,
|
|
running_days: 0
|
|
}));
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async updateJob(id: string, jobData: UpdateJobRequest): Promise<Job | null> {
|
|
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 !== undefined) {
|
|
updateFields.push('status = ?');
|
|
values.push(jobData.status);
|
|
}
|
|
if (jobData.evaluation_criteria !== undefined) {
|
|
updateFields.push('evaluation_criteria = ?');
|
|
values.push(JSON.stringify(jobData.evaluation_criteria ?? null));
|
|
}
|
|
if (jobData.interview_questions !== undefined) {
|
|
updateFields.push('interview_questions = ?');
|
|
const questionsArray = Array.isArray(jobData.interview_questions) ? jobData.interview_questions : [];
|
|
values.push(JSON.stringify(questionsArray));
|
|
}
|
|
if ((jobData as any).interview_style !== undefined) {
|
|
updateFields.push('interview_style = ?');
|
|
values.push((jobData as any).interview_style);
|
|
}
|
|
if (jobData.application_deadline !== undefined) {
|
|
updateFields.push('application_deadline = ?');
|
|
values.push(jobData.application_deadline);
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
async deleteJob(id: string): Promise<void> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
await connection.execute(
|
|
'UPDATE jobs SET deleted_at = NOW(), updated_at = NOW() WHERE id = ? AND deleted_at IS NULL',
|
|
[id]
|
|
);
|
|
} catch (error) {
|
|
$log.error('Error deleting job:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async getAllJobs(): Promise<Job[]> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
const [rows] = await connection.execute(
|
|
`SELECT
|
|
j.*,
|
|
DATEDIFF(NOW(), j.created_at) AS running_days,
|
|
COUNT(DISTINCT i.id) AS total_interviews,
|
|
SUM(CASE WHEN i.status = 'completed' THEN 1 ELSE 0 END) AS interviews_completed,
|
|
COUNT(DISTINCT c.id) AS applications
|
|
FROM jobs j
|
|
LEFT JOIN interviews i ON i.job_id = j.id
|
|
LEFT JOIN candidates c ON c.job_id = j.id
|
|
WHERE j.deleted_at IS NULL
|
|
GROUP BY j.id
|
|
ORDER BY j.created_at DESC`
|
|
);
|
|
|
|
if (Array.isArray(rows)) {
|
|
return rows.map((job: any) => {
|
|
const parsed = {
|
|
...job,
|
|
skills_required: this.parseJsonField(job.skills_required, []),
|
|
evaluation_criteria: this.parseJsonField(job.evaluation_criteria, {}),
|
|
interview_questions: this.parseJsonField(job.interview_questions, []),
|
|
interview_style: job.interview_style || 'balanced'
|
|
} as any;
|
|
parsed.total_interviews = Number(job.total_interviews || 0);
|
|
parsed.interviews_completed = Number(job.interviews_completed || 0);
|
|
parsed.available_interviews = Math.max(
|
|
0,
|
|
parsed.total_interviews - parsed.interviews_completed
|
|
);
|
|
parsed.applications = Number(job.applications || 0);
|
|
parsed.running_days = Number(job.running_days || 0);
|
|
return parsed;
|
|
});
|
|
}
|
|
|
|
return [];
|
|
} catch (error) {
|
|
$log.error('Error getting all jobs (with metrics). Falling back to basic query:', error);
|
|
// Fallback: simple query without metrics
|
|
const [rows] = await connection.execute(
|
|
'SELECT * FROM jobs WHERE deleted_at IS NULL ORDER BY created_at DESC'
|
|
);
|
|
const jobs = Array.isArray(rows) ? rows : [];
|
|
return jobs.map((job: any) => ({
|
|
...job,
|
|
skills_required: this.parseJsonField(job.skills_required, []),
|
|
evaluation_criteria: this.parseJsonField(job.evaluation_criteria, {}),
|
|
interview_questions: this.parseJsonField(job.interview_questions, []),
|
|
interview_style: job.interview_style || 'balanced',
|
|
total_interviews: 0,
|
|
interviews_completed: 0,
|
|
available_interviews: 0,
|
|
applications: 0,
|
|
running_days: 0
|
|
}));
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async getJobLinks(jobId: string): Promise<JobLink[]> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
const [rows] = await connection.execute(
|
|
'SELECT * FROM job_links WHERE job_id = ? ORDER BY created_at DESC',
|
|
[jobId]
|
|
);
|
|
|
|
if (Array.isArray(rows)) {
|
|
return rows.map(link => ({
|
|
...link,
|
|
tokens_used: this.getTokensUsed(link)
|
|
})) as JobLink[];
|
|
}
|
|
return [];
|
|
} catch (error) {
|
|
$log.error('Error getting job links:', error);
|
|
return [];
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async createJobLink(jobId: string, tokensAvailable: number = 0): Promise<JobLink> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
const linkId = randomUUID();
|
|
const urlSlug = randomUUID().substring(0, 8); // Short URL slug
|
|
|
|
await connection.execute(
|
|
'INSERT INTO job_links (id, job_id, url_slug, tokens_available, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
|
|
[linkId, jobId, urlSlug, tokensAvailable]
|
|
);
|
|
|
|
const [rows] = await connection.execute(
|
|
'SELECT * FROM job_links WHERE id = ?',
|
|
[linkId]
|
|
);
|
|
|
|
if (Array.isArray(rows) && rows.length > 0) {
|
|
const link = rows[0];
|
|
return {
|
|
...link,
|
|
tokens_used: this.getTokensUsed(link)
|
|
} as JobLink;
|
|
}
|
|
|
|
throw new Error('Failed to create job link');
|
|
} catch (error) {
|
|
$log.error('Error creating job link:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async addTokensToLink(linkId: string, tokensToAdd: number, userId: string): Promise<JobLink> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
// First get current tokens
|
|
const [rows] = await connection.execute(
|
|
'SELECT * FROM job_links WHERE id = ?',
|
|
[linkId]
|
|
);
|
|
|
|
if (!Array.isArray(rows) || rows.length === 0) {
|
|
throw new Error('Job link not found');
|
|
}
|
|
|
|
const currentLink = rows[0] as JobLink;
|
|
const newTokenCount = currentLink.tokens_available + tokensToAdd;
|
|
|
|
// Update tokens
|
|
await connection.execute(
|
|
'UPDATE job_links SET tokens_available = ?, updated_at = NOW() WHERE id = ?',
|
|
[newTokenCount, linkId]
|
|
);
|
|
|
|
// Consume user's tokens from interview_tokens table
|
|
// Find active token packages and consume tokens from them
|
|
const [tokenRows] = await connection.execute(
|
|
'SELECT id, tokens_remaining FROM interview_tokens WHERE user_id = ? AND status = "active" AND tokens_remaining > 0 ORDER BY created_at ASC',
|
|
[userId]
|
|
);
|
|
|
|
let remainingToConsume = tokensToAdd;
|
|
for (const tokenRow of Array.isArray(tokenRows) ? tokenRows : []) {
|
|
if (remainingToConsume <= 0) break;
|
|
|
|
const availableInThisPackage = Math.min(tokenRow.tokens_remaining, remainingToConsume);
|
|
const newRemaining = tokenRow.tokens_remaining - availableInThisPackage;
|
|
const newUsed = tokenRow.tokens_remaining - newRemaining;
|
|
|
|
await connection.execute(
|
|
'UPDATE interview_tokens SET tokens_used = tokens_used + ?, updated_at = NOW() WHERE id = ?',
|
|
[availableInThisPackage, tokenRow.id]
|
|
);
|
|
|
|
remainingToConsume -= availableInThisPackage;
|
|
}
|
|
|
|
if (remainingToConsume > 0) {
|
|
throw new Error(`Insufficient tokens available. Only ${tokensToAdd - remainingToConsume} tokens could be consumed.`);
|
|
}
|
|
|
|
// Return updated link
|
|
const [updatedRows] = await connection.execute(
|
|
'SELECT * FROM job_links WHERE id = ?',
|
|
[linkId]
|
|
);
|
|
|
|
if (Array.isArray(updatedRows) && updatedRows.length > 0) {
|
|
const link = updatedRows[0];
|
|
return {
|
|
...link,
|
|
tokens_used: this.getTokensUsed(link)
|
|
} as JobLink;
|
|
}
|
|
|
|
throw new Error('Failed to update job link');
|
|
} catch (error) {
|
|
$log.error('Error adding tokens to link:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async removeTokensFromLink(linkId: string, tokensToRemove: number, userId: string): Promise<JobLink> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
// First get current tokens
|
|
const [rows] = await connection.execute(
|
|
'SELECT * FROM job_links WHERE id = ?',
|
|
[linkId]
|
|
);
|
|
|
|
if (!Array.isArray(rows) || rows.length === 0) {
|
|
throw new Error('Job link not found');
|
|
}
|
|
|
|
const currentLink = rows[0] as JobLink;
|
|
|
|
// Check if we have enough tokens to remove
|
|
if (currentLink.tokens_available < tokensToRemove) {
|
|
throw new Error(`Cannot remove ${tokensToRemove} tokens. Link only has ${currentLink.tokens_available} tokens available.`);
|
|
}
|
|
|
|
const newTokenCount = currentLink.tokens_available - tokensToRemove;
|
|
|
|
// Update tokens
|
|
await connection.execute(
|
|
'UPDATE job_links SET tokens_available = ?, updated_at = NOW() WHERE id = ?',
|
|
[newTokenCount, linkId]
|
|
);
|
|
|
|
// Return tokens to user by increasing tokens_remaining in interview_tokens
|
|
// Find the most recently used token package to return tokens to
|
|
const [tokenRows] = await connection.execute(
|
|
'SELECT id, tokens_remaining, quantity, tokens_used FROM interview_tokens WHERE user_id = ? AND status = "active" ORDER BY updated_at DESC',
|
|
[userId]
|
|
);
|
|
|
|
console.log('Token removal - Found token packages:', tokenRows);
|
|
|
|
if (Array.isArray(tokenRows) && tokenRows.length > 0) {
|
|
// Return tokens to the most recently used package
|
|
const tokenRow = tokenRows[0];
|
|
console.log('Token removal - Returning to package:', tokenRow);
|
|
|
|
const newRemaining = Math.min(tokenRow.tokens_remaining + tokensToRemove, tokenRow.quantity);
|
|
const tokensToReturn = newRemaining - tokenRow.tokens_remaining;
|
|
const newUsed = Math.max(0, tokenRow.tokens_used - tokensToReturn);
|
|
|
|
console.log('Token removal - New values:', { newRemaining, tokensToReturn, newUsed });
|
|
|
|
await connection.execute(
|
|
'UPDATE interview_tokens SET tokens_used = ?, updated_at = NOW() WHERE id = ?',
|
|
[newUsed, tokenRow.id]
|
|
);
|
|
|
|
console.log('Token removal - Updated package successfully');
|
|
} else {
|
|
console.log('Token removal - No active token packages found for user');
|
|
}
|
|
|
|
// Return updated link
|
|
const [updatedRows] = await connection.execute(
|
|
'SELECT * FROM job_links WHERE id = ?',
|
|
[linkId]
|
|
);
|
|
|
|
if (Array.isArray(updatedRows) && updatedRows.length > 0) {
|
|
const link = updatedRows[0];
|
|
return {
|
|
...link,
|
|
tokens_used: this.getTokensUsed(link)
|
|
} as JobLink;
|
|
}
|
|
|
|
throw new Error('Failed to update job link');
|
|
} catch (error) {
|
|
$log.error('Error removing tokens from link:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async deleteJobLink(linkId: string, userId: string): Promise<{ tokensReturned: number }> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
// First get the link to check how many tokens it has
|
|
const [rows] = await connection.execute(
|
|
'SELECT * FROM job_links WHERE id = ?',
|
|
[linkId]
|
|
);
|
|
|
|
if (!Array.isArray(rows) || rows.length === 0) {
|
|
throw new Error('Job link not found');
|
|
}
|
|
|
|
const link = rows[0] as JobLink;
|
|
const tokensToReturn = link.tokens_available;
|
|
|
|
console.log('Deleting job link with', tokensToReturn, 'tokens to return');
|
|
|
|
// Return all tokens to user if there are any
|
|
if (tokensToReturn > 0) {
|
|
// Find the most recently used token package to return tokens to
|
|
const [tokenRows] = await connection.execute(
|
|
'SELECT id, tokens_remaining, quantity, tokens_used FROM interview_tokens WHERE user_id = ? AND status = "active" ORDER BY updated_at DESC',
|
|
[userId]
|
|
);
|
|
|
|
console.log('Link deletion - Found token packages:', tokenRows);
|
|
|
|
if (Array.isArray(tokenRows) && tokenRows.length > 0) {
|
|
// Return tokens to the most recently used package
|
|
const tokenRow = tokenRows[0];
|
|
console.log('Link deletion - Returning to package:', tokenRow);
|
|
|
|
const newRemaining = Math.min(tokenRow.tokens_remaining + tokensToReturn, tokenRow.quantity);
|
|
const tokensToReturnToPackage = newRemaining - tokenRow.tokens_remaining;
|
|
const newUsed = Math.max(0, tokenRow.tokens_used - tokensToReturnToPackage);
|
|
|
|
console.log('Link deletion - New values:', { newRemaining, tokensToReturnToPackage, newUsed });
|
|
|
|
await connection.execute(
|
|
'UPDATE interview_tokens SET tokens_used = ?, updated_at = NOW() WHERE id = ?',
|
|
[newUsed, tokenRow.id]
|
|
);
|
|
|
|
console.log('Link deletion - Updated package successfully');
|
|
} else {
|
|
console.log('Link deletion - No active token packages found for user');
|
|
}
|
|
}
|
|
|
|
// Delete the job link
|
|
await connection.execute(
|
|
'DELETE FROM job_links WHERE id = ?',
|
|
[linkId]
|
|
);
|
|
|
|
console.log('Link deletion - Job link deleted successfully');
|
|
|
|
return { tokensReturned: tokensToReturn };
|
|
} catch (error) {
|
|
$log.error('Error deleting job link:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async getJobByLinkId(linkId: string): Promise<Job | null> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
const [rows] = await connection.execute(
|
|
`SELECT j.* FROM jobs j
|
|
INNER JOIN job_links jl ON j.id = jl.job_id
|
|
WHERE jl.url_slug = ? AND j.deleted_at IS NULL`,
|
|
[linkId]
|
|
);
|
|
|
|
if (Array.isArray(rows) && rows.length > 0) {
|
|
const job = rows[0] as any;
|
|
return {
|
|
...job,
|
|
skills_required: this.parseJsonField(job.skills_required, []),
|
|
evaluation_criteria: this.parseJsonField(job.evaluation_criteria, {}),
|
|
interview_questions: this.parseJsonField(job.interview_questions, []),
|
|
interview_style: job.interview_style || 'balanced'
|
|
};
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
$log.error('Error getting job by link ID:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async submitInterview(linkId: string, answers: any): Promise<void> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
// Get the job ID from the link
|
|
const [linkRows] = await connection.execute(
|
|
'SELECT job_id FROM job_links WHERE url_slug = ?',
|
|
[linkId]
|
|
);
|
|
|
|
if (!Array.isArray(linkRows) || linkRows.length === 0) {
|
|
throw new Error('Interview link not found');
|
|
}
|
|
|
|
const jobId = (linkRows[0] as any).job_id;
|
|
|
|
// Create a candidate record
|
|
const candidateId = randomUUID();
|
|
await connection.execute(
|
|
'INSERT INTO candidates (id, job_id, email, name, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
|
|
[candidateId, jobId, 'interview@candidate.com', 'Interview Candidate']
|
|
);
|
|
|
|
// Create an interview record
|
|
const interviewId = randomUUID();
|
|
await connection.execute(
|
|
'INSERT INTO interviews (id, job_id, candidate_id, status, answers, created_at, updated_at) VALUES (?, ?, ?, ?, ?, NOW(), NOW())',
|
|
[interviewId, jobId, candidateId, 'completed', JSON.stringify(answers)]
|
|
);
|
|
|
|
// Decrement tokens from the link
|
|
await connection.execute(
|
|
'UPDATE job_links SET tokens_available = tokens_available - 1, updated_at = NOW() WHERE url_slug = ?',
|
|
[linkId]
|
|
);
|
|
|
|
} catch (error) {
|
|
$log.error('Error submitting interview:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async logInterviewEvent(linkId: string, eventType: string, data: any): Promise<void> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
// Get the job ID from the link
|
|
const [linkRows] = await connection.execute(
|
|
'SELECT job_id FROM job_links WHERE url_slug = ?',
|
|
[linkId]
|
|
);
|
|
|
|
if (!Array.isArray(linkRows) || linkRows.length === 0) {
|
|
throw new Error('Interview link not found');
|
|
}
|
|
|
|
const jobId = (linkRows[0] as any).job_id;
|
|
|
|
// Log the event
|
|
await connection.execute(
|
|
'INSERT INTO interview_events (id, job_id, link_id, event_type, event_data, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
|
|
[randomUUID(), jobId, linkId, eventType, JSON.stringify(data)]
|
|
);
|
|
|
|
} catch (error) {
|
|
$log.error('Error logging interview event:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async logFailedAttempt(linkId: string): Promise<void> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
// Get the job ID from the link
|
|
const [linkRows] = await connection.execute(
|
|
'SELECT job_id FROM job_links WHERE url_slug = ?',
|
|
[linkId]
|
|
);
|
|
|
|
if (!Array.isArray(linkRows) || linkRows.length === 0) {
|
|
throw new Error('Interview link not found');
|
|
}
|
|
|
|
const jobId = (linkRows[0] as any).job_id;
|
|
|
|
// Log the failed attempt (no token deduction)
|
|
await connection.execute(
|
|
'INSERT INTO interview_events (id, job_id, link_id, event_type, event_data, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
|
|
[randomUUID(), jobId, linkId, 'consent_declined', JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
reason: 'User declined consent'
|
|
})]
|
|
);
|
|
|
|
} catch (error) {
|
|
$log.error('Error logging failed attempt:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async getOrCreateInterview(linkId: string, candidateName: string, isTestMode: boolean = false): Promise<string> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
// Get the job ID and user ID from the link and job
|
|
const [linkRows] = await connection.execute(
|
|
`SELECT jl.job_id, j.user_id
|
|
FROM job_links jl
|
|
INNER JOIN jobs j ON jl.job_id = j.id
|
|
WHERE jl.url_slug = ?`,
|
|
[linkId]
|
|
);
|
|
|
|
if (!Array.isArray(linkRows) || linkRows.length === 0) {
|
|
throw new Error('Interview link not found');
|
|
}
|
|
|
|
const jobId = (linkRows[0] as any).job_id;
|
|
const userId = (linkRows[0] as any).user_id;
|
|
|
|
// For test mode, just return a mock interview ID without saving to database
|
|
if (isTestMode) {
|
|
return `test-interview-${linkId}-${Date.now()}`;
|
|
}
|
|
|
|
// Check if interview already exists for this link
|
|
const [existingInterviews] = await connection.execute(
|
|
'SELECT id FROM interviews WHERE job_id = ? AND token = ?',
|
|
[jobId, linkId]
|
|
);
|
|
|
|
if (Array.isArray(existingInterviews) && existingInterviews.length > 0) {
|
|
return (existingInterviews[0] as any).id;
|
|
}
|
|
|
|
// Create new interview
|
|
const interviewId = randomUUID();
|
|
const candidateId = randomUUID();
|
|
|
|
// Create candidate record with unique email
|
|
const candidateEmail = `interview-${candidateId}@candidate.com`;
|
|
await connection.execute(
|
|
'INSERT INTO candidates (id, user_id, job_id, email, first_name, last_name, status, applied_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW(), NOW())',
|
|
[candidateId, userId, jobId, candidateEmail, candidateName.split(' ')[0] || candidateName, candidateName.split(' ').slice(1).join(' ') || '', 'interviewing']
|
|
);
|
|
|
|
// Create interview record
|
|
await connection.execute(
|
|
'INSERT INTO interviews (id, user_id, candidate_id, job_id, token, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())',
|
|
[interviewId, userId, candidateId, jobId, linkId, 'in_progress']
|
|
);
|
|
|
|
return interviewId;
|
|
} catch (error) {
|
|
$log.error('Error getting or creating interview:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async saveConversationMessage(interviewId: string, linkId: string, sender: 'candidate' | 'ai', message: string, isTestMode: boolean = false): Promise<void> {
|
|
// Skip database writes in test mode
|
|
if (isTestMode) {
|
|
console.log(`[TEST MODE] Would save message: ${sender}: ${message.substring(0, 50)}...`);
|
|
return;
|
|
}
|
|
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
await connection.execute(
|
|
'INSERT INTO conversation_messages (id, interview_id, link_id, sender, message, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
|
|
[randomUUID(), interviewId, linkId, sender, message]
|
|
);
|
|
} catch (error) {
|
|
$log.error('Error saving conversation message:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async getConversationHistory(interviewId: string): Promise<any[]> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
const [rows] = await connection.execute(
|
|
'SELECT sender, message, created_at FROM conversation_messages WHERE interview_id = ? ORDER BY created_at ASC',
|
|
[interviewId]
|
|
);
|
|
|
|
return Array.isArray(rows) ? rows.map((row: any) => ({
|
|
sender: row.sender,
|
|
message: row.message,
|
|
timestamp: row.created_at
|
|
})) : [];
|
|
} catch (error) {
|
|
$log.error('Error getting conversation history:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async getInterviewIdByLink(linkId: string): Promise<string | null> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
const [linkRows] = await connection.execute(
|
|
'SELECT job_id FROM job_links WHERE url_slug = ?',
|
|
[linkId]
|
|
);
|
|
|
|
if (!Array.isArray(linkRows) || linkRows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const jobId = (linkRows[0] as any).job_id;
|
|
|
|
const [interviewRows] = await connection.execute(
|
|
'SELECT id FROM interviews WHERE job_id = ? ORDER BY created_at DESC LIMIT 1',
|
|
[jobId]
|
|
);
|
|
|
|
if (Array.isArray(interviewRows) && interviewRows.length > 0) {
|
|
return (interviewRows[0] as any).id;
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
$log.error('Error getting interview ID by link:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
|
|
async completeInterview(interviewId: string): Promise<void> {
|
|
const connection = await pool.getConnection();
|
|
|
|
try {
|
|
await connection.execute(
|
|
'UPDATE interviews SET status = ?, completed_at = NOW(), updated_at = NOW() WHERE id = ?',
|
|
['completed', interviewId]
|
|
);
|
|
} catch (error) {
|
|
$log.error('Error completing interview:', error);
|
|
throw error;
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
}
|