diff --git a/backend/src/Server.ts b/backend/src/Server.ts
index e89a2ba..fa06462 100644
--- a/backend/src/Server.ts
+++ b/backend/src/Server.ts
@@ -35,7 +35,12 @@ import {$log} from "@tsed/logger";
version: process.env.APP_VERSION || "1.0.0",
description:
"REST API for Candivista. Authentication via JWT Bearer tokens.\n\n" +
- "Includes endpoints for auth, users, jobs, tokens, AI, and admin reporting.",
+ "Includes endpoints for auth, users, jobs, tokens, AI-powered interviews (OpenRouter/Ollama), and admin reporting.\n\n" +
+ "AI Features:\n" +
+ "- OpenRouter integration for cloud-based AI interviews\n" +
+ "- Ollama support for local AI processing\n" +
+ "- Test mode for admin interview testing\n" +
+ "- Mandatory question support before AI interviews",
contact: {
name: "Candivista Team",
url: "https://candivista.com",
@@ -51,7 +56,7 @@ import {$log} from "@tsed/logger";
{ name: "Users", description: "User profile and token summary" },
{ name: "Jobs", description: "Job posting and interview token operations" },
{ name: "Admin", description: "Administrative statistics and management" },
- { name: "AI", description: "AI provider tests and operations" }
+ { name: "AI", description: "AI-powered interview operations with OpenRouter and Ollama support" }
],
components: {
securitySchemes: {
diff --git a/backend/src/controllers/rest/AIController.ts b/backend/src/controllers/rest/AIController.ts
index dc814f1..2f93861 100644
--- a/backend/src/controllers/rest/AIController.ts
+++ b/backend/src/controllers/rest/AIController.ts
@@ -1,5 +1,5 @@
import { Controller } from "@tsed/di";
-import { Post, Get } from "@tsed/schema";
+import { Post, Get, Tags, Summary, Description, Returns, Security } from "@tsed/schema";
import { BodyParams, PathParams, QueryParams } from "@tsed/platform-params";
import { Req } from "@tsed/platform-http";
import { BadRequest, NotFound } from "@tsed/exceptions";
@@ -8,6 +8,7 @@ import { AIService } from "../../services/AIService.js";
import axios from "axios";
@Controller("/ai")
+@Tags("AI")
export class AIController {
private jobService = new JobService();
private aiService = new AIService();
@@ -17,6 +18,9 @@ export class AIController {
// Test AI connection
@Get("/test-ai")
+ @Summary("Test AI connection")
+ @Description("Test the AI service connection and configuration. Works with both Ollama and OpenRouter providers.")
+ @(Returns(200, Object).Description("AI test result with success status and response"))
async testAI() {
try {
if (this.aiProvider === 'openrouter') {
@@ -60,6 +64,10 @@ export class AIController {
// Get mandatory questions for the job
@Get("/mandatory-questions/:linkId")
+ @Summary("Get mandatory interview questions")
+ @Description("Retrieve mandatory questions for a specific job interview link")
+ @(Returns(200, Object).Description("List of mandatory questions for the job"))
+ @(Returns(404, Object).Description("Interview link not found or expired"))
async getMandatoryQuestions(@PathParams("linkId") linkId: string) {
try {
// Verify the job exists and link is valid
@@ -83,6 +91,11 @@ export class AIController {
// Submit mandatory question answers
@Post("/submit-mandatory-answers")
+ @Summary("Submit mandatory question answers")
+ @Description("Submit answers to mandatory interview questions before starting the AI interview")
+ @(Returns(200, Object).Description("Success response with interview data"))
+ @(Returns(400, Object).Description("Missing required fields"))
+ @(Returns(404, Object).Description("Interview link not found or expired"))
async submitMandatoryAnswers(@BodyParams() body: any, @QueryParams() query: any) {
try {
const { candidateName, job, linkId, answers } = body;
@@ -135,6 +148,12 @@ export class AIController {
// Start interview with AI agent (only after mandatory questions)
@Post("/start-interview")
+ @Summary("Start AI interview")
+ @Description("Initialize an AI-powered interview session. Can be used in test mode for admins.")
+ @(Returns(200, Object).Description("Interview started successfully with initial AI message"))
+ @(Returns(400, Object).Description("Missing required fields"))
+ @(Returns(404, Object).Description("Interview link not found or expired"))
+ @(Returns(500, Object).Description("AI service unavailable"))
async startInterview(@BodyParams() body: any, @QueryParams() query: any) {
try {
const { candidateName, job, linkId } = body;
@@ -197,6 +216,12 @@ export class AIController {
// Handle chat messages
@Post("/chat")
+ @Summary("Send chat message to AI")
+ @Description("Send a message to the AI interviewer and receive a response. Supports both test and production modes.")
+ @(Returns(200, Object).Description("AI response message"))
+ @(Returns(400, Object).Description("Missing required fields"))
+ @(Returns(404, Object).Description("Interview link not found or expired"))
+ @(Returns(500, Object).Description("AI service unavailable"))
async handleChat(@BodyParams() body: any, @QueryParams() query: any) {
try {
const { message, candidateName, job, linkId, conversationHistory } = body;
@@ -226,7 +251,7 @@ export class AIController {
console.log(`[DEBUG] Using frontend conversation history (test mode): ${JSON.stringify(conversationHistoryToUse, null, 2)}`);
// Filter out any messages with undefined content
- conversationHistoryToUse = conversationHistoryToUse.filter(msg =>
+ conversationHistoryToUse = conversationHistoryToUse.filter((msg: any) =>
msg && msg.message && msg.message !== 'undefined' && msg.sender
);
console.log(`[DEBUG] Filtered conversation history: ${JSON.stringify(conversationHistoryToUse, null, 2)}`);
@@ -275,6 +300,10 @@ export class AIController {
// Get conversation history
@Get("/conversation/:linkId")
+ @Summary("Get conversation history")
+ @Description("Retrieve the conversation history for a specific interview")
+ @(Returns(200, Object).Description("Conversation history messages"))
+ @(Returns(404, Object).Description("Interview link not found or expired"))
async getConversation(@PathParams("linkId") linkId: string) {
try {
const jobData = await this.jobService.getJobByLinkId(linkId);
@@ -304,6 +333,10 @@ export class AIController {
// End interview
@Post("/end-interview/:linkId")
+ @Summary("End interview session")
+ @Description("End an active interview session and mark it as completed")
+ @(Returns(200, Object).Description("Interview ended successfully"))
+ @(Returns(404, Object).Description("Interview link or interview not found"))
async endInterview(@PathParams("linkId") linkId: string) {
try {
const jobData = await this.jobService.getJobByLinkId(linkId);
diff --git a/backend/src/controllers/rest/AdminController.ts b/backend/src/controllers/rest/AdminController.ts
index 43bf3a1..9d30eee 100644
--- a/backend/src/controllers/rest/AdminController.ts
+++ b/backend/src/controllers/rest/AdminController.ts
@@ -246,4 +246,21 @@ export class AdminController {
await this.checkAdmin(req);
return await this.adminService.getPaymentById(id);
}
+
+ // Job Links Management
+ @Get("/jobs/:id/links")
+ @Summary("Get job links")
+ @(Returns(200).Description("Job links returned"))
+ async getJobLinks(@Req() req: any, @PathParams("id") id: string) {
+ await this.checkAdmin(req);
+ return await this.adminService.getJobLinks(id);
+ }
+
+ @Post("/jobs/:id/create-link")
+ @Summary("Create job link for testing")
+ @(Returns(200).Description("Job link created"))
+ async createJobLink(@Req() req: any, @PathParams("id") id: string, @BodyParams() linkData: any) {
+ await this.checkAdmin(req);
+ return await this.adminService.createJobLink(id, linkData.tokensAvailable || 1);
+ }
}
diff --git a/backend/src/services/AdminService.ts b/backend/src/services/AdminService.ts
index 2c108c4..f4edb2b 100644
--- a/backend/src/services/AdminService.ts
+++ b/backend/src/services/AdminService.ts
@@ -773,4 +773,58 @@ export class AdminService {
connection.release();
}
}
+
+ // Job Links Management
+ async getJobLinks(jobId: string) {
+ 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;
+ }
+ return [];
+ } catch (error) {
+ $log.error('Error getting job links:', error);
+ return [];
+ } finally {
+ connection.release();
+ }
+ }
+
+ async createJobLink(jobId: string, tokensAvailable: number = 1) {
+ const connection = await pool.getConnection();
+
+ try {
+ // Generate a random URL slug and UUID
+ const linkId = randomUUID();
+ const urlSlug = randomUUID().replace(/-/g, '').substring(0, 8);
+
+ 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]
+ );
+
+ // Get the created link
+ const [rows] = await connection.execute(
+ 'SELECT * FROM job_links WHERE id = ?',
+ [linkId]
+ );
+
+ if (Array.isArray(rows) && rows.length > 0) {
+ return rows[0];
+ }
+
+ throw new Error('Failed to create job link');
+ } catch (error) {
+ $log.error('Error creating job link:', error);
+ throw error;
+ } finally {
+ connection.release();
+ }
+ }
}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 7b46634..cbf4f7f 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
+import axios from "axios";
import AnimatedCounter from "@/components/AnimatedCounter";
import FeatureCard from "@/components/FeatureCard";
import PricingCard from "@/components/PricingCard";
@@ -17,7 +18,27 @@ export default function Home() {
// Check if user is already logged in
const token = localStorage.getItem("token");
if (token) {
- router.push("/dashboard");
+ // Check if user is admin - if so, allow them to stay on landing page
+ // Non-admin users will be redirected to dashboard
+ axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/auth/me`, {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ })
+ .then(response => {
+ const userData = response.data;
+ if (userData.role !== 'admin') {
+ router.push("/dashboard");
+ } else {
+ setIsLoading(false);
+ }
+ })
+ .catch(() => {
+ // If token is invalid, remove it and stay on landing page
+ localStorage.removeItem("token");
+ localStorage.removeItem("user");
+ setIsLoading(false);
+ });
} else {
setIsLoading(false);
}
diff --git a/frontend/src/components/JobManagement.tsx b/frontend/src/components/JobManagement.tsx
index f889f3b..4161026 100644
--- a/frontend/src/components/JobManagement.tsx
+++ b/frontend/src/components/JobManagement.tsx
@@ -137,6 +137,42 @@ export default function JobManagement() {
setIsAddTokensModalOpen(true);
};
+ const handleTestInterview = async (job: Job) => {
+ try {
+ const token = localStorage.getItem("token");
+
+ // Get job links for this job
+ const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/jobs/${job.id}/links`, {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ });
+
+ let linkId;
+ if (response.data && response.data.length > 0) {
+ // Use the first available link
+ linkId = response.data[0].url_slug;
+ } else {
+ // Create a test link if none exists
+ const createLinkResponse = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/rest/admin/jobs/${job.id}/create-link`, {
+ tokensAvailable: 1
+ }, {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ });
+ linkId = createLinkResponse.data.url_slug;
+ }
+
+ // Open interview page in test mode with the correct link ID
+ const testUrl = `/interview?id=${linkId}&test=true`;
+ window.open(testUrl, '_blank');
+ } catch (error) {
+ console.error("Failed to get job link for testing:", error);
+ alert("Failed to create test interview link. Please try again.");
+ }
+ };
+
const handleToggleJobStatus = async (job: Job) => {
try {
const token = localStorage.getItem("token");
@@ -392,6 +428,12 @@ export default function JobManagement() {
>
Add Tokens
+