added a test link so the admin can test the interviews too, admin can now go to the landing page too, fixes for the swagger links

This commit is contained in:
Nixon 2025-09-20 15:04:56 +02:00
parent 824bf93dfb
commit 7a868d7f14
7 changed files with 178 additions and 5 deletions

View File

@ -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: {

View File

@ -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);

View File

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

View File

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

View File

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

View File

@ -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
</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>
<button
onClick={() => handleToggleJobStatus(job)}

1
tuna Submodule

@ -0,0 +1 @@
Subproject commit bcd25503c5fa7562e1fb6cef8b719dbf39b5bab1