From 7a868d7f14f8ca2fb46dfe238be47cfdc0682282 Mon Sep 17 00:00:00 2001 From: Nixon Date: Sat, 20 Sep 2025 15:04:56 +0200 Subject: [PATCH] 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 --- backend/src/Server.ts | 9 +++- backend/src/controllers/rest/AIController.ts | 37 ++++++++++++- .../src/controllers/rest/AdminController.ts | 17 ++++++ backend/src/services/AdminService.ts | 54 +++++++++++++++++++ frontend/src/app/page.tsx | 23 +++++++- frontend/src/components/JobManagement.tsx | 42 +++++++++++++++ tuna | 1 + 7 files changed, 178 insertions(+), 5 deletions(-) create mode 160000 tuna 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 +