import { Controller } from "@tsed/di"; import { Post, Get, Delete, Put, Patch, Tags, Summary, Description, Returns, Security } from "@tsed/schema"; import { BodyParams, PathParams } from "@tsed/platform-params"; import { Req } from "@tsed/platform-http"; import { Unauthorized, NotFound } from "@tsed/exceptions"; import jwt from "jsonwebtoken"; import { pool } from "../../config/database.js"; import { UserService } from "../../services/UserService.js"; import { JobService } from "../../services/JobService.js"; import { TokenService } from "../../services/TokenService.js"; const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; @Controller("/jobs") @Tags("Jobs") export class JobController { private userService = new UserService(); private jobService = new JobService(); private tokenService = new TokenService(); // Middleware to check if user is authenticated private async checkAuth(req: any) { const token = req.headers.authorization?.replace("Bearer ", ""); if (!token) { throw new Unauthorized("No token provided"); } try { const decoded = jwt.verify(token, JWT_SECRET) as any; const user = await this.userService.getUserById(decoded.userId); if (!user) { throw new Unauthorized("User not found"); } return user; } catch (error) { throw new Unauthorized("Invalid token"); } } // Create a new job @Post("/") @Security("bearerAuth") @Summary("Create a new job") @Description("Recruiters and admins can create a job posting.") @(Returns(200).Description("Job created successfully")) @(Returns(401).Description("Unauthorized or missing token")) async createJob(@Req() req: any, @BodyParams() jobData: any) { try { console.log('=== JOB CREATION START ==='); console.log('Job creation request received:', JSON.stringify(jobData, null, 2)); console.log('Request headers:', req.headers); // Test database connection first try { const connection = await pool.getConnection(); console.log('Database connection successful'); connection.release(); } catch (dbError) { console.error('Database connection failed:', dbError); throw new Error('Database connection failed: ' + dbError.message); } const user = await this.checkAuth(req); console.log('User authenticated:', user.email, user.role); // Check if user can create a job (basic validation) if (user.role !== 'recruiter' && user.role !== 'admin') { throw new Unauthorized("Only recruiters can create jobs"); } // Validate required fields if (!jobData.title || !jobData.description || !jobData.requirements) { throw new Error("Missing required fields: title, description, or requirements"); } console.log('All validations passed, creating job...'); const createdJob = await this.jobService.createJob(user.id, jobData); console.log('Job created successfully:', createdJob.id); return { success: true, job: createdJob, message: "Job created successfully" }; } catch (error) { console.error('=== JOB CREATION ERROR ==='); console.error('Error type:', (error as any).constructor.name); console.error('Error message:', (error as any).message); console.error('Error stack:', (error as any).stack); console.error('Full error object:', error as any); throw error; } } // Test endpoint to check if the controller is working @Get("/test") @Summary("Test endpoint") @Description("Returns a simple heartbeat for Job controller") @(Returns(200).Description("Service reachable")) async testEndpoint() { return { success: true, message: "JobController is working!", timestamp: new Date().toISOString() }; } // Get all jobs for a user @Get("/") @Security("bearerAuth") @Summary("List jobs") @Description("Recruiters see their jobs; admins see all jobs.") @(Returns(200).Description("Array of jobs returned")) @(Returns(401).Description("Unauthorized")) async getJobs(@Req() req: any) { try { const user = await this.checkAuth(req); console.log('Fetching jobs for user:', user.email, user.role); if (user.role === 'recruiter') { // Recruiters can only see their own jobs const jobs = await this.jobService.getJobsByUserId(user.id); return { success: true, jobs: jobs }; } else if (user.role === 'admin') { // Admins can see all jobs const jobs = await this.jobService.getAllJobs(); return { success: true, jobs: jobs }; } else { throw new Unauthorized("Only recruiters and admins can access jobs"); } } catch (error: any) { console.error('Error fetching jobs:', error); throw error; } } // Get a single job by ID @Get("/:id") @Security("bearerAuth") @Summary("Get a job by ID") @(Returns(200).Description("Job found")) @(Returns(401).Description("Unauthorized")) @(Returns(404).Description("Job not found")) async getJobById(@Req() req: any, @PathParams("id") id: string) { try { const user = await this.checkAuth(req); console.log('Fetching job by ID:', id, 'for user:', user.email); const job = await this.jobService.getJobById(id); if (!job) { throw new NotFound("Job not found"); } // Check if user can access this job if (user.role === 'recruiter' && job.user_id !== user.id) { throw new Unauthorized("You can only view your own jobs"); } // Get job links if any const links = await this.jobService.getJobLinks(id); return { success: true, job: { ...job, links: links } }; } catch (error: any) { console.error('Error fetching job by ID:', error); throw error; } } // Update a job (recruiter owns it or admin) @Put("/:id") @Security("bearerAuth") @Summary("Update a job") @(Returns(200).Description("Job updated")) @(Returns(401).Description("Unauthorized")) @(Returns(404).Description("Job not found")) async updateJob(@Req() req: any, @PathParams("id") id: string, @BodyParams() body: any) { const user = await this.checkAuth(req); const job = await this.jobService.getJobById(id); if (!job) { throw new NotFound("Job not found"); } if (user.role === 'recruiter' && job.user_id !== user.id) { throw new Unauthorized("You can only update your own jobs"); } const updated = await this.jobService.updateJob(id, body); return { success: true, job: updated }; } // Update job status @Patch("/:id/status") @Security("bearerAuth") @Summary("Update job status") @(Returns(200).Description("Job status updated")) @(Returns(401).Description("Unauthorized")) @(Returns(404).Description("Job not found")) async updateJobStatus(@Req() req: any, @PathParams("id") id: string, @BodyParams() body: { status: string }) { const user = await this.checkAuth(req); const job = await this.jobService.getJobById(id); if (!job) { throw new NotFound("Job not found"); } if (user.role === 'recruiter' && job.user_id !== user.id) { throw new Unauthorized("You can only update your own jobs"); } const updated = await this.jobService.updateJobStatus(id, body.status); return { success: true, job: updated }; } // Create a job link @Post("/:id/links") @Security("bearerAuth") @Summary("Create interview link for a job") @(Returns(200).Description("Link created")) @(Returns(401).Description("Unauthorized")) @(Returns(404).Description("Job not found")) async createJobLink(@Req() req: any, @PathParams("id") id: string, @BodyParams() linkData: any) { try { const user = await this.checkAuth(req); console.log('Creating job link for job:', id, 'by user:', user.email); // Verify job exists and user has access const job = await this.jobService.getJobById(id); if (!job) { throw new NotFound("Job not found"); } if (user.role === 'recruiter' && job.user_id !== user.id) { throw new Unauthorized("You can only create links for your own jobs"); } const link = await this.jobService.createJobLink(id, linkData.tokens_available || 0); return { success: true, link: link, message: "Job link created successfully" }; } catch (error: any) { console.error('Error creating job link:', error); throw error; } } // Add tokens to a job link @Post("/:id/links/:linkId/tokens") @Security("bearerAuth") @Summary("Add tokens to a job link") @(Returns(200).Description("Tokens added")) @(Returns(400).Description("Insufficient tokens or invalid amount")) @(Returns(401).Description("Unauthorized")) @(Returns(404).Description("Job not found")) async addTokensToLink(@Req() req: any, @PathParams("id") id: string, @PathParams("linkId") linkId: string, @BodyParams() tokenData: any) { try { const user = await this.checkAuth(req); console.log('Adding tokens to link:', linkId, 'for job:', id, 'by user:', user.email); // Verify job exists and user has access const job = await this.jobService.getJobById(id); if (!job) { throw new NotFound("Job not found"); } if (user.role === 'recruiter' && job.user_id !== user.id) { throw new Unauthorized("You can only modify links for your own jobs"); } // Check if user has enough tokens const tokenSummary = await this.tokenService.getUserTokenSummary(user.id); const tokensToAdd = tokenData.tokens || 0; if (tokenSummary.total_available < tokensToAdd) { return { success: false, error: "INSUFFICIENT_TOKENS", message: `You don't have enough tokens. You have ${tokenSummary.total_available} tokens available, but need ${tokensToAdd}.`, available_tokens: tokenSummary.total_available, requested_tokens: tokensToAdd }; } const updatedLink = await this.jobService.addTokensToLink(linkId, tokensToAdd, user.id); return { success: true, link: updatedLink, message: "Tokens added successfully" }; } catch (error: any) { console.error('Error adding tokens to link:', error); throw error; } } // Remove tokens from a job link @Delete("/:id/links/:linkId/tokens") @Security("bearerAuth") @Summary("Remove tokens from a job link") @(Returns(200).Description("Tokens removed")) @(Returns(400).Description("Invalid amount")) @(Returns(401).Description("Unauthorized")) @(Returns(404).Description("Job not found")) async removeTokensFromLink(@Req() req: any, @PathParams("id") id: string, @PathParams("linkId") linkId: string, @BodyParams() tokenData: any) { try { const user = await this.checkAuth(req); console.log('Removing tokens from link:', linkId, 'for job:', id, 'by user:', user.email); // Verify job exists and user has access const job = await this.jobService.getJobById(id); if (!job) { throw new NotFound("Job not found"); } if (user.role === 'recruiter' && job.user_id !== user.id) { throw new Unauthorized("You can only modify links for your own jobs"); } const tokensToRemove = tokenData.tokens || 0; if (tokensToRemove <= 0) { return { success: false, error: "INVALID_AMOUNT", message: "Please specify a valid number of tokens to remove." }; } const updatedLink = await this.jobService.removeTokensFromLink(linkId, tokensToRemove, user.id); return { success: true, link: updatedLink, message: "Tokens removed successfully" }; } catch (error: any) { console.error('Error removing tokens from link:', error); throw error; } } // Delete a job link @Delete("/:id/links/:linkId") @Security("bearerAuth") @Summary("Delete a job link") @(Returns(200).Description("Link deleted; tokens returned if applicable")) @(Returns(401).Description("Unauthorized")) @(Returns(404).Description("Job not found")) async deleteJobLink(@Req() req: any, @PathParams("id") id: string, @PathParams("linkId") linkId: string) { try { const user = await this.checkAuth(req); console.log('Deleting job link:', linkId, 'for job:', id, 'by user:', user.email); // Verify job exists and user has access const job = await this.jobService.getJobById(id); if (!job) { throw new NotFound("Job not found"); } if (user.role === 'recruiter' && job.user_id !== user.id) { throw new Unauthorized("You can only modify links for your own jobs"); } const result = await this.jobService.deleteJobLink(linkId, user.id); return { success: true, message: "Job link deleted successfully", tokensReturned: result.tokensReturned }; } catch (error: any) { console.error('Error deleting job link:', error); throw error; } } // Get job by interview link (public endpoint) @Get("/interview/:linkId") @Summary("Get job by interview link") @Description("Public endpoint used by candidates to load interview context.") @(Returns(200).Description("Job returned")) @(Returns(404).Description("Interview link not found or expired")) async getJobByLink(@PathParams("linkId") linkId: string) { try { console.log('Getting job by link ID:', linkId); const job = await this.jobService.getJobByLinkId(linkId); if (!job) { throw new NotFound("Interview link not found or expired"); } return { success: true, job: job }; } catch (error: any) { console.error('Error getting job by link:', error); throw error; } } // Submit interview responses @Post("/interview/:linkId/submit") @Summary("Submit interview responses") @Description("Submits candidate answers; if not a test, consumes one token.") @(Returns(200).Description("Submission acknowledged")) @(Returns(404).Description("Interview link not found or expired")) async submitInterview(@PathParams("linkId") linkId: string, @BodyParams() submissionData: any) { try { console.log('Submitting interview for link:', linkId); const job = await this.jobService.getJobByLinkId(linkId); if (!job) { throw new NotFound("Interview link not found or expired"); } // If it's not a test, save the interview if (!submissionData.isTest) { await this.jobService.submitInterview(linkId, submissionData.answers); } return { success: true, message: submissionData.isTest ? "Test interview completed" : "Interview submitted successfully" }; } catch (error: any) { console.error('Error submitting interview:', error); throw error; } } // Log failed interview attempt (consent declined) @Post("/interview/:linkId/failed") @Summary("Log a failed interview attempt") @Description("Records consent decline or early exit without consuming tokens.") @(Returns(200).Description("Event recorded")) @(Returns(404).Description("Interview link not found or expired")) async logFailedAttempt(@PathParams("linkId") linkId: string) { try { console.log('Logging failed attempt for link:', linkId); const job = await this.jobService.getJobByLinkId(linkId); if (!job) { throw new NotFound("Interview link not found or expired"); } // Log the failed attempt (no token deduction) await this.jobService.logFailedAttempt(linkId); return { success: true, message: "Failed attempt logged successfully" }; } catch (error: any) { console.error('Error logging failed attempt:', error); throw error; } } // Health check endpoint @Get("/health") @Summary("Health check") @Description("Reports DB connectivity and service health") @(Returns(200).Description("Healthy or unhealthy status returned")) async healthCheck() { try { // Test database connection const connection = await pool.getConnection(); connection.release(); return { status: "healthy", database: "connected", timestamp: new Date().toISOString() }; } catch (error) { return { status: "unhealthy", database: "disconnected", error: (error as any).message, timestamp: new Date().toISOString() }; } } }