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