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