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