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();
}
}
}