Compare commits

2 Commits
8 changed files with 180 additions and 5 deletions
+2
View File
@@ -20,3 +20,5 @@ This will start the application with all necessary services.
--- ---
This README provides the basic steps to configure and run the AISApp project using Docker Compose and environment variables. This README provides the basic steps to configure and run the AISApp project using Docker Compose and environment variables.
portainer= tunaadmin/tunatainer8!
+7 -2
View File
@@ -35,7 +35,12 @@ import {$log} from "@tsed/logger";
version: process.env.APP_VERSION || "1.0.0", version: process.env.APP_VERSION || "1.0.0",
description: description:
"REST API for Candivista. Authentication via JWT Bearer tokens.\n\n" + "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: { contact: {
name: "Candivista Team", name: "Candivista Team",
url: "https://candivista.com", url: "https://candivista.com",
@@ -51,7 +56,7 @@ import {$log} from "@tsed/logger";
{ name: "Users", description: "User profile and token summary" }, { name: "Users", description: "User profile and token summary" },
{ name: "Jobs", description: "Job posting and interview token operations" }, { name: "Jobs", description: "Job posting and interview token operations" },
{ name: "Admin", description: "Administrative statistics and management" }, { 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: { components: {
securitySchemes: { securitySchemes: {
+35 -2
View File
@@ -1,5 +1,5 @@
import { Controller } from "@tsed/di"; 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 { BodyParams, PathParams, QueryParams } from "@tsed/platform-params";
import { Req } from "@tsed/platform-http"; import { Req } from "@tsed/platform-http";
import { BadRequest, NotFound } from "@tsed/exceptions"; import { BadRequest, NotFound } from "@tsed/exceptions";
@@ -8,6 +8,7 @@ import { AIService } from "../../services/AIService.js";
import axios from "axios"; import axios from "axios";
@Controller("/ai") @Controller("/ai")
@Tags("AI")
export class AIController { export class AIController {
private jobService = new JobService(); private jobService = new JobService();
private aiService = new AIService(); private aiService = new AIService();
@@ -17,6 +18,9 @@ export class AIController {
// Test AI connection // Test AI connection
@Get("/test-ai") @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() { async testAI() {
try { try {
if (this.aiProvider === 'openrouter') { if (this.aiProvider === 'openrouter') {
@@ -60,6 +64,10 @@ export class AIController {
// Get mandatory questions for the job // Get mandatory questions for the job
@Get("/mandatory-questions/:linkId") @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) { async getMandatoryQuestions(@PathParams("linkId") linkId: string) {
try { try {
// Verify the job exists and link is valid // Verify the job exists and link is valid
@@ -83,6 +91,11 @@ export class AIController {
// Submit mandatory question answers // Submit mandatory question answers
@Post("/submit-mandatory-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) { async submitMandatoryAnswers(@BodyParams() body: any, @QueryParams() query: any) {
try { try {
const { candidateName, job, linkId, answers } = body; const { candidateName, job, linkId, answers } = body;
@@ -135,6 +148,12 @@ export class AIController {
// Start interview with AI agent (only after mandatory questions) // Start interview with AI agent (only after mandatory questions)
@Post("/start-interview") @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) { async startInterview(@BodyParams() body: any, @QueryParams() query: any) {
try { try {
const { candidateName, job, linkId } = body; const { candidateName, job, linkId } = body;
@@ -197,6 +216,12 @@ export class AIController {
// Handle chat messages // Handle chat messages
@Post("/chat") @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) { async handleChat(@BodyParams() body: any, @QueryParams() query: any) {
try { try {
const { message, candidateName, job, linkId, conversationHistory } = body; 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)}`); console.log(`[DEBUG] Using frontend conversation history (test mode): ${JSON.stringify(conversationHistoryToUse, null, 2)}`);
// Filter out any messages with undefined content // Filter out any messages with undefined content
conversationHistoryToUse = conversationHistoryToUse.filter(msg => conversationHistoryToUse = conversationHistoryToUse.filter((msg: any) =>
msg && msg.message && msg.message !== 'undefined' && msg.sender msg && msg.message && msg.message !== 'undefined' && msg.sender
); );
console.log(`[DEBUG] Filtered conversation history: ${JSON.stringify(conversationHistoryToUse, null, 2)}`); console.log(`[DEBUG] Filtered conversation history: ${JSON.stringify(conversationHistoryToUse, null, 2)}`);
@@ -275,6 +300,10 @@ export class AIController {
// Get conversation history // Get conversation history
@Get("/conversation/:linkId") @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) { async getConversation(@PathParams("linkId") linkId: string) {
try { try {
const jobData = await this.jobService.getJobByLinkId(linkId); const jobData = await this.jobService.getJobByLinkId(linkId);
@@ -304,6 +333,10 @@ export class AIController {
// End interview // End interview
@Post("/end-interview/:linkId") @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) { async endInterview(@PathParams("linkId") linkId: string) {
try { try {
const jobData = await this.jobService.getJobByLinkId(linkId); const jobData = await this.jobService.getJobByLinkId(linkId);
@@ -246,4 +246,21 @@ export class AdminController {
await this.checkAdmin(req); await this.checkAdmin(req);
return await this.adminService.getPaymentById(id); 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);
}
} }
+54
View File
@@ -773,4 +773,58 @@ export class AdminService {
connection.release(); 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();
}
}
} }
+21
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import axios from "axios";
import AnimatedCounter from "@/components/AnimatedCounter"; import AnimatedCounter from "@/components/AnimatedCounter";
import FeatureCard from "@/components/FeatureCard"; import FeatureCard from "@/components/FeatureCard";
import PricingCard from "@/components/PricingCard"; import PricingCard from "@/components/PricingCard";
@@ -17,10 +18,30 @@ export default function Home() {
// Check if user is already logged in // Check if user is already logged in
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (token) { if (token) {
// 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"); router.push("/dashboard");
} else { } else {
setIsLoading(false); 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);
}
}, [router]); }, [router]);
useEffect(() => { useEffect(() => {
+42
View File
@@ -137,6 +137,42 @@ export default function JobManagement() {
setIsAddTokensModalOpen(true); 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) => { const handleToggleJobStatus = async (job: Job) => {
try { try {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
@@ -392,6 +428,12 @@ export default function JobManagement() {
> >
Add Tokens Add Tokens
</button> </button>
<button
onClick={() => handleTestInterview(job)}
className="text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-300 text-sm font-medium"
>
Test Interview
</button>
</div> </div>
<button <button
onClick={() => handleToggleJobStatus(job)} onClick={() => handleToggleJobStatus(job)}
Submodule
+1
Submodule tuna added at bcd25503c5