feat(flow): implement e2e flow with n8n and db
This commit is contained in:
parent
f663d04e10
commit
b751ee556f
9 changed files with 1540 additions and 65 deletions
|
|
@ -1,80 +1,137 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase";
|
||||
import { generateEmbedding } from "@/lib/embeddings";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const files = formData.getAll("files") as File[];
|
||||
const file = formData.get("file") as File | null;
|
||||
const jobId = formData.get("jobId") as string | null;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return NextResponse.json({ error: "No files uploaded" }, { status: 400 });
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: "Missing jobId" }, { status: 400 });
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Extract text from PDF using PDFParse v2 API
|
||||
const parser = new PDFParse({ data: buffer });
|
||||
const pdfData = await parser.getText();
|
||||
const text = pdfData.text;
|
||||
await parser.destroy();
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Extract candidate name from file name (strip extension)
|
||||
const name = file.name.replace(/\.[^/.]+$/, "");
|
||||
// Extract text from PDF using PDFParse v2 API
|
||||
const parser = new PDFParse({ data: buffer });
|
||||
const pdfData = await parser.getText();
|
||||
const text = pdfData.text;
|
||||
await parser.destroy();
|
||||
|
||||
// Extract email using basic regex
|
||||
const emailRegex = /[\w.-]+@[\w.-]+\.\w+/;
|
||||
const emailMatch = text.match(emailRegex);
|
||||
const email = emailMatch ? emailMatch[0] : "unknown@example.com";
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: "Failed to extract text from PDF" }, { status: 400 });
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
// Clean text
|
||||
const cleanText = text.replace(/\s+/g, " ").trim();
|
||||
|
||||
// Extract name from file (strip extension)
|
||||
const name = file.name.replace(/\.[^/.]+$/, "");
|
||||
|
||||
// Extract email and phone using regex
|
||||
const emailRegex = /[\w.-]+@[\w.-]+\.\w+/;
|
||||
const emailMatch = text.match(emailRegex);
|
||||
const email = emailMatch ? emailMatch[0] : "unknown@example.com";
|
||||
|
||||
const phoneRegex = /(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/;
|
||||
const phoneMatch = text.match(phoneRegex);
|
||||
const phone = phoneMatch ? phoneMatch[0] : "Not provided";
|
||||
|
||||
// Generate candidate embedding
|
||||
const embedding = await generateEmbedding(cleanText);
|
||||
|
||||
// Initialize Supabase admin client
|
||||
const supabase = createServerSupabaseClient();
|
||||
|
||||
// Insert candidate
|
||||
const { data: candidate, error: candidateError } = await supabase
|
||||
.from("candidates")
|
||||
.insert({
|
||||
name,
|
||||
email,
|
||||
text,
|
||||
});
|
||||
}
|
||||
contact_info: { email, phone },
|
||||
embedding,
|
||||
})
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
|
||||
if (!webhookUrl) {
|
||||
return NextResponse.json({ error: "Webhook URL not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Send the array of candidates to n8n Webhook
|
||||
const n8nResponse = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: "web",
|
||||
candidates,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!n8nResponse.ok) {
|
||||
const errText = await n8nResponse.text();
|
||||
if (candidateError || !candidate) {
|
||||
return NextResponse.json(
|
||||
{ error: `n8n webhook call failed: ${n8nResponse.status} - ${errText}` },
|
||||
{ status: 502 }
|
||||
{ error: candidateError?.message || "Failed to insert candidate" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if response has content
|
||||
let responseData = null;
|
||||
const contentType = n8nResponse.headers.get("content-type");
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
responseData = await n8nResponse.json();
|
||||
// Insert an initial interview
|
||||
const { data: interview, error: interviewError } = await supabase
|
||||
.from("interviews")
|
||||
.insert({
|
||||
candidate_id: candidate.id,
|
||||
job_id: jobId,
|
||||
interview_date: new Date().toISOString(),
|
||||
stage: "Screening",
|
||||
})
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
if (interviewError || !interview) {
|
||||
return NextResponse.json(
|
||||
{ error: interviewError?.message || "Failed to insert interview" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Call n8n webhook
|
||||
let n8nResponseData = null;
|
||||
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
|
||||
if (webhookUrl) {
|
||||
try {
|
||||
const n8nResponse = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
candidateId: candidate.id,
|
||||
interviewId: interview.id,
|
||||
candidateName: candidate.name,
|
||||
candidateEmail: email,
|
||||
text: cleanText,
|
||||
}),
|
||||
});
|
||||
|
||||
if (n8nResponse.ok) {
|
||||
const contentType = n8nResponse.headers.get("content-type");
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
n8nResponseData = await n8nResponse.json();
|
||||
} else {
|
||||
n8nResponseData = { message: await n8nResponse.text() };
|
||||
}
|
||||
} else {
|
||||
const errText = await n8nResponse.text();
|
||||
n8nResponseData = { error: `n8n response not ok: ${n8nResponse.status} - ${errText}` };
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
n8nResponseData = { error: err instanceof Error ? err.message : "Failed to call n8n webhook" };
|
||||
}
|
||||
} else {
|
||||
responseData = { message: await n8nResponse.text() };
|
||||
n8nResponseData = { message: "NEXT_PUBLIC_N8N_WEBHOOK_URL is not set" };
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "CVs processed and forwarded to n8n successfully",
|
||||
data: responseData,
|
||||
candidateId: candidate.id,
|
||||
interviewId: interview.id,
|
||||
candidateName: candidate.name,
|
||||
candidateEmail: email,
|
||||
n8nResponse: n8nResponseData,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error("Error in parse-cv route:", error);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,170 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
interface Score {
|
||||
id: string;
|
||||
candidate_id: string;
|
||||
ai_score: number;
|
||||
evaluation: {
|
||||
summary: string;
|
||||
classification: string;
|
||||
suggestions: string;
|
||||
riskLevel: string;
|
||||
};
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
id: string;
|
||||
name: string;
|
||||
contact_info: {
|
||||
email: string;
|
||||
phone: string;
|
||||
};
|
||||
scores?: Score[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function CandidatesPage() {
|
||||
const [candidates, setCandidates] = useState<Candidate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetch("/api/candidates")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Failed to fetch candidates");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (active) {
|
||||
setCandidates(data);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
||||
<h1 className="text-xl font-bold text-slate-900 mb-2">Candidates</h1>
|
||||
<p className="text-slate-600 text-sm">View and evaluate parsed candidate profiles.</p>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Candidates</h1>
|
||||
<p className="text-slate-600 text-sm">
|
||||
A list of all candidates parsed and analyzed by the AI recruitment pipeline.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
||||
<p className="text-slate-500 text-sm">Loading candidates...</p>
|
||||
</div>
|
||||
) : candidates.length === 0 ? (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
||||
<p className="text-slate-500 text-sm">
|
||||
No candidates found. Upload CVs on the Jobs tab to parse them.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{candidates.map((candidate) => {
|
||||
// Get the latest score
|
||||
const latestScore =
|
||||
candidate.scores && candidate.scores.length > 0
|
||||
? candidate.scores[0]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={candidate.id}
|
||||
className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex flex-col gap-4"
|
||||
>
|
||||
{/* Candidate Info Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-slate-200">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-900">
|
||||
{candidate.name}
|
||||
</h2>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
Email:{" "}
|
||||
<span className="text-slate-600 font-medium">
|
||||
{candidate.contact_info.email}
|
||||
</span>
|
||||
<span className="mx-2">|</span>
|
||||
Phone:{" "}
|
||||
<span className="text-slate-600 font-medium">
|
||||
{candidate.contact_info.phone}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{latestScore ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<span className="block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
AI Assessment
|
||||
</span>
|
||||
<span className="text-xl font-bold text-blue-600">
|
||||
{latestScore.ai_score} / 10
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-slate-50 text-slate-600 text-xs font-semibold rounded-md border border-slate-200">
|
||||
{latestScore.evaluation.classification}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-1 bg-slate-50 text-slate-500 text-xs font-semibold rounded-md border border-slate-200">
|
||||
Pending Evaluation
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score details if available */}
|
||||
{latestScore ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">
|
||||
AI Summary
|
||||
</span>
|
||||
<p className="text-slate-600 leading-relaxed">
|
||||
{latestScore.evaluation.summary}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
Risk Level:{" "}
|
||||
<span className="font-semibold text-slate-600">
|
||||
{latestScore.evaluation.riskLevel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">
|
||||
Action Items / Suggestions
|
||||
</span>
|
||||
<p className="text-slate-600 leading-relaxed whitespace-pre-line">
|
||||
{latestScore.evaluation.suggestions}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500 text-sm italic">
|
||||
This candidate's CV has been indexed, but the AI evaluation
|
||||
has not yet completed. The background n8n workflow updates
|
||||
scores upon completion.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,231 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
interface Interview {
|
||||
id: string;
|
||||
candidate_id: string;
|
||||
job_id: string;
|
||||
interview_date: string;
|
||||
stage: string;
|
||||
feedback: string | null;
|
||||
created_at: string;
|
||||
candidates: {
|
||||
name: string;
|
||||
} | null;
|
||||
jobs: {
|
||||
title: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function InterviewsPage() {
|
||||
const [interviews, setInterviews] = useState<Interview[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
const [editStates, setEditStates] = useState<
|
||||
Record<string, { stage: string; feedback: string }>
|
||||
>({});
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function fetchInterviews() {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from("interviews")
|
||||
.select("*, candidates(name), jobs(title)")
|
||||
.order("interview_date", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
if (active) {
|
||||
const typedData = (data as unknown as Interview[]) || [];
|
||||
setInterviews(typedData);
|
||||
|
||||
// Initialize edit states
|
||||
const initialEditStates: Record<string, { stage: string; feedback: string }> = {};
|
||||
typedData.forEach((item) => {
|
||||
initialEditStates[item.id] = {
|
||||
stage: item.stage,
|
||||
feedback: item.feedback || "",
|
||||
};
|
||||
});
|
||||
setEditStates(initialEditStates);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching interviews:", err);
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchInterviews();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleStateChange = (id: string, field: "stage" | "feedback", value: string) => {
|
||||
setEditStates((prev) => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
...prev[id],
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string) => {
|
||||
const editState = editStates[id];
|
||||
if (!editState) return;
|
||||
|
||||
try {
|
||||
setUpdatingId(id);
|
||||
setActionMessage(null);
|
||||
|
||||
const { error } = await supabase
|
||||
.from("interviews")
|
||||
.update({
|
||||
stage: editState.stage,
|
||||
feedback: editState.feedback,
|
||||
})
|
||||
.eq("id", id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setActionMessage("Interview updated successfully!");
|
||||
// Hide message after 3 seconds
|
||||
setTimeout(() => setActionMessage(null), 3000);
|
||||
|
||||
// Refresh interview data locally
|
||||
setInterviews((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, stage: editState.stage, feedback: editState.feedback }
|
||||
: item
|
||||
)
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
console.error("Error updating interview:", err);
|
||||
setActionMessage("Failed to update interview.");
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
||||
<h1 className="text-xl font-bold text-slate-900 mb-2">Interviews</h1>
|
||||
<p className="text-slate-600 text-sm">Schedule and monitor candidate evaluations.</p>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Interviews</h1>
|
||||
<p className="text-slate-600 text-sm">
|
||||
Manage scheduled candidate interview stages and write evaluation feedback.
|
||||
</p>
|
||||
</div>
|
||||
{actionMessage && (
|
||||
<div className="px-4 py-2 bg-slate-50 border border-slate-200 rounded-md text-xs font-semibold text-slate-600">
|
||||
{actionMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
||||
<p className="text-slate-500 text-sm">Loading interviews...</p>
|
||||
</div>
|
||||
) : interviews.length === 0 ? (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
||||
<p className="text-slate-500 text-sm">
|
||||
No interviews scheduled. Upload candidate CVs under the Jobs tab to trigger evaluations.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{interviews.map((interview) => {
|
||||
const currentEdit = editStates[interview.id] || {
|
||||
stage: interview.stage,
|
||||
feedback: interview.feedback || "",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={interview.id}
|
||||
className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex flex-col gap-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-slate-200">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-slate-900">
|
||||
{interview.candidates?.name || "Unknown Candidate"}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 font-medium">
|
||||
Role: {interview.jobs?.title || "Unknown Job"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Date Scheduled:{" "}
|
||||
{new Date(interview.interview_date).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
Stage:
|
||||
</label>
|
||||
<select
|
||||
value={currentEdit.stage}
|
||||
onChange={(e) =>
|
||||
handleStateChange(interview.id, "stage", e.target.value)
|
||||
}
|
||||
className="px-2 py-1 text-sm bg-white border border-slate-200 rounded-md text-slate-900 focus:outline-none"
|
||||
>
|
||||
<option value="Screening">Screening</option>
|
||||
<option value="Technical">Technical</option>
|
||||
<option value="Cultural">Cultural</option>
|
||||
<option value="Offer">Offer</option>
|
||||
<option value="Hired">Hired</option>
|
||||
<option value="Rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback Area */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
Interview Feedback
|
||||
</label>
|
||||
<textarea
|
||||
value={currentEdit.feedback}
|
||||
onChange={(e) =>
|
||||
handleStateChange(
|
||||
interview.id,
|
||||
"feedback",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="Write detailed assessment feedback, questions, or observations..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-md text-slate-900 bg-white placeholder:text-slate-500 text-sm focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => handleUpdate(interview.id)}
|
||||
disabled={updatingId === interview.id}
|
||||
className="py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-md transition duration-200 disabled:opacity-50"
|
||||
>
|
||||
{updatingId === interview.id ? "Saving..." : "Update Interview"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,407 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
title: string;
|
||||
requirements: { text: string };
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Score {
|
||||
id: string;
|
||||
candidate_id: string;
|
||||
ai_score: number;
|
||||
evaluation: {
|
||||
summary: string;
|
||||
classification: string;
|
||||
suggestions: string;
|
||||
riskLevel: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
id: string;
|
||||
name: string;
|
||||
contact_info: {
|
||||
email: string;
|
||||
phone: string;
|
||||
};
|
||||
similarity?: number;
|
||||
scores?: Score[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function JobsPage() {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
||||
const [matches, setMatches] = useState<Candidate[]>([]);
|
||||
|
||||
const [loadingJobs, setLoadingJobs] = useState(true);
|
||||
const [loadingMatches, setLoadingMatches] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
|
||||
|
||||
// Form states
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [newRequirements, setNewRequirements] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
// Fetch all jobs on mount
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetch("/api/jobs")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Failed to fetch jobs");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (active) {
|
||||
setJobs(data);
|
||||
setLoadingJobs(false);
|
||||
if (data.length > 0) {
|
||||
setSelectedJob(data[0]);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
if (active) {
|
||||
setLoadingJobs(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch candidates/matches when selected job changes
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
if (!selectedJob) {
|
||||
Promise.resolve().then(() => {
|
||||
if (active) setMatches([]);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve().then(() => {
|
||||
if (active) setLoadingMatches(true);
|
||||
});
|
||||
|
||||
fetch(`/api/candidates?jobId=${selectedJob.id}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Failed to fetch candidate matches");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (active) {
|
||||
setMatches(data);
|
||||
setLoadingMatches(false);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
if (active) {
|
||||
setLoadingMatches(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selectedJob]);
|
||||
|
||||
// Create vacancy
|
||||
const handleCreateJob = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newTitle.trim() || !newRequirements.trim()) {
|
||||
setFormError("All fields are required");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setFormError(null);
|
||||
const res = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: newTitle, requirements: newRequirements }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
throw new Error(errData.error || "Failed to create vacancy");
|
||||
}
|
||||
const newJob = await res.json();
|
||||
setJobs((prev) => [newJob, ...prev]);
|
||||
setSelectedJob(newJob);
|
||||
setNewTitle("");
|
||||
setNewRequirements("");
|
||||
} catch (err: unknown) {
|
||||
setFormError(err instanceof Error ? err.message : "Error creating job");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Upload PDF CV
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !selectedJob) return;
|
||||
|
||||
if (file.type !== "application/pdf") {
|
||||
setUploadError("Please upload a PDF file");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
setUploadError(null);
|
||||
setUploadSuccess(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("jobId", selectedJob.id);
|
||||
|
||||
const res = await fetch("/candidates/api/parse-cv", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
throw new Error(errData.error || "Failed to process CV");
|
||||
}
|
||||
|
||||
setUploadSuccess(`CV for ${file.name} successfully parsed and indexed!`);
|
||||
|
||||
// Refresh matches for current job
|
||||
const matchesRes = await fetch(`/api/candidates?jobId=${selectedJob.id}`);
|
||||
if (matchesRes.ok) {
|
||||
const matchesData = await matchesRes.json();
|
||||
setMatches(matchesData);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setUploadError(err instanceof Error ? err.message : "Error uploading CV");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
// Clear file input
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
||||
<h1 className="text-xl font-bold text-slate-900 mb-2">Jobs</h1>
|
||||
<p className="text-slate-600 text-sm">Manage job vacancies and candidate matches.</p>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column: Create Form & Vacancies List */}
|
||||
<div className="lg:col-span-1 flex flex-col gap-6">
|
||||
{/* Create vacancy form */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
||||
<h2 className="text-lg font-bold text-slate-900 mb-4">Create Vacancy</h2>
|
||||
<form onSubmit={handleCreateJob} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">
|
||||
Job Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="e.g., Senior React Developer"
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-md text-slate-900 bg-white placeholder:text-slate-500 text-sm focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">
|
||||
Requirements text
|
||||
</label>
|
||||
<textarea
|
||||
value={newRequirements}
|
||||
onChange={(e) => setNewRequirements(e.target.value)}
|
||||
placeholder="Describe key candidate qualifications and tech stack..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-md text-slate-900 bg-white placeholder:text-slate-500 text-sm focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{formError && (
|
||||
<p className="text-xs text-red-600 font-semibold">{formError}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-md transition duration-200 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create Vacancy"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Vacancy list */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex-1">
|
||||
<h2 className="text-lg font-bold text-slate-900 mb-4">Job Vacancies</h2>
|
||||
{loadingJobs ? (
|
||||
<p className="text-slate-500 text-sm">Loading jobs...</p>
|
||||
) : jobs.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">No vacancies created yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{jobs.map((job) => (
|
||||
<button
|
||||
key={job.id}
|
||||
onClick={() => {
|
||||
setSelectedJob(job);
|
||||
setUploadError(null);
|
||||
setUploadSuccess(null);
|
||||
}}
|
||||
className={`w-full text-left p-3 rounded-md border text-sm transition duration-200 ${
|
||||
selectedJob?.id === job.id
|
||||
? "border-blue-600 bg-slate-50 font-semibold"
|
||||
: "border-slate-200 hover:border-slate-300"
|
||||
}`}
|
||||
>
|
||||
<div className="text-slate-900">{job.title}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
Created: {new Date(job.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Selected Job Details & Candidate Match */}
|
||||
<div className="lg:col-span-2">
|
||||
{selectedJob ? (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
Vacancy Details
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mt-1">
|
||||
{selectedJob.title}
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
ID: {selectedJob.id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div className="p-4 bg-slate-50 rounded-md border border-slate-200">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-2">
|
||||
Requirements
|
||||
</h3>
|
||||
<p className="text-slate-600 text-sm whitespace-pre-wrap">
|
||||
{selectedJob.requirements.text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* PDF Uploader */}
|
||||
<div className="border border-dashed border-slate-200 rounded-lg p-6 flex flex-col items-center justify-center text-center">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-1">
|
||||
Upload Candidate CV (PDF)
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mb-4 max-w-md">
|
||||
Uploading a candidate CV parses the text, calculates its semantic matching score, schedules a screening interview, and signals n8n workflow.
|
||||
</p>
|
||||
<label className="relative cursor-pointer bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md text-sm transition duration-200">
|
||||
{uploading ? "Processing CV..." : "Choose CV File"}
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{uploadError && (
|
||||
<p className="text-xs text-red-600 mt-3 font-semibold">
|
||||
{uploadError}
|
||||
</p>
|
||||
)}
|
||||
{uploadSuccess && (
|
||||
<p className="text-xs text-green-600 mt-3 font-semibold">
|
||||
{uploadSuccess}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Matches List */}
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-slate-900 mb-3">
|
||||
Matched Candidates (Semantic Similarity)
|
||||
</h3>
|
||||
{loadingMatches ? (
|
||||
<p className="text-slate-500 text-sm">Finding matches...</p>
|
||||
) : matches.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">
|
||||
No candidates have been uploaded or matched yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{matches.map((match) => {
|
||||
const similarityPct = match.similarity
|
||||
? Math.round(match.similarity * 100)
|
||||
: null;
|
||||
const latestScore = match.scores?.[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={match.id}
|
||||
className="p-4 rounded-md border border-slate-200 flex flex-col sm:flex-row sm:items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-slate-900 font-bold text-sm">
|
||||
{match.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
Email: {match.contact_info.email} | Phone:{" "}
|
||||
{match.contact_info.phone}
|
||||
</div>
|
||||
{latestScore && (
|
||||
<div className="text-xs text-slate-600 mt-1">
|
||||
<span className="font-semibold">AI Decision:</span>{" "}
|
||||
{latestScore.evaluation.classification} (Score:{" "}
|
||||
{latestScore.ai_score}/10)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{similarityPct !== null && (
|
||||
<div className="text-right">
|
||||
<span className="block text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
Match Score
|
||||
</span>
|
||||
<span className="text-lg font-bold text-blue-600">
|
||||
{similarityPct}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white p-12 rounded-lg shadow-sm border border-slate-200 flex flex-col items-center justify-center text-center">
|
||||
<p className="text-slate-600 font-semibold mb-2">
|
||||
Select or create a job vacancy to get started
|
||||
</p>
|
||||
<p className="text-slate-500 text-xs max-w-sm">
|
||||
Use the sidebar panel to choose a vacancy or fill in the form to establish a new open position.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,102 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
export default function WorkflowsPage() {
|
||||
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL || "";
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (webhookUrl) {
|
||||
navigator.clipboard.writeText(webhookUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
||||
<h1 className="text-xl font-bold text-slate-900 mb-2">Workflows</h1>
|
||||
<p className="text-slate-600 text-sm">Configure automated recruitment pipelines orchestrated by n8n.</p>
|
||||
<div className="flex flex-col gap-6 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Workflows</h1>
|
||||
<p className="text-slate-600 text-sm">
|
||||
Monitor active n8n webhooks and background integration pipelines.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200 flex flex-col gap-6">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-slate-900">n8n Webhook Integration</h2>
|
||||
<p className="text-slate-500 text-xs mt-1">
|
||||
This webhook coordinates CV processing and automatic screening candidates scores updates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Integration Status Badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
Status:
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-semibold rounded-md border ${
|
||||
webhookUrl
|
||||
? "bg-slate-50 text-slate-600 border-slate-200"
|
||||
: "bg-slate-50 text-slate-500 border-slate-200"
|
||||
}`}
|
||||
>
|
||||
{webhookUrl ? "Active" : "Inactive / Missing Env"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Webhook Input/Copy Panel */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
Active Webhook Target URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={webhookUrl || "No webhook URL configured. Set NEXT_PUBLIC_N8N_WEBHOOK_URL in environment."}
|
||||
className="flex-1 px-3 py-2 border border-slate-200 rounded-md text-slate-900 bg-slate-50 text-sm focus:outline-none"
|
||||
/>
|
||||
{webhookUrl && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-md transition duration-200"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informational Pipeline Flow */}
|
||||
<div className="pt-4 border-t border-slate-200 flex flex-col gap-3">
|
||||
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
Automated Recruitment Pipeline Execution
|
||||
</h3>
|
||||
<div className="flex flex-col gap-3 text-sm text-slate-600">
|
||||
<div className="flex gap-3 items-start">
|
||||
<span className="font-bold text-blue-600">1.</span>
|
||||
<p>
|
||||
<strong>CV Ingestion:</strong> CVs uploaded on the Jobs screen are parsed, and candidate records are stored in Supabase with candidate vector embeddings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 items-start">
|
||||
<span className="font-bold text-blue-600">2.</span>
|
||||
<p>
|
||||
<strong>Webhook Trigger:</strong> The backend route calls the n8n webhook URL with candidate meta-information and parsed CV text.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 items-start">
|
||||
<span className="font-bold text-blue-600">3.</span>
|
||||
<p>
|
||||
<strong>AI Review & Evaluation:</strong> n8n runs the screening workflow, generates scores, sets classification fields, and populates the database suggestions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
111
app/api/candidates/route.ts
Normal file
111
app/api/candidates/route.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase";
|
||||
|
||||
interface CandidateScore {
|
||||
id: string;
|
||||
candidate_id: string;
|
||||
ai_score: number;
|
||||
evaluation: {
|
||||
summary: string;
|
||||
classification: string;
|
||||
suggestions: string;
|
||||
riskLevel: string;
|
||||
};
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface RankedCandidate {
|
||||
id: string;
|
||||
name: string;
|
||||
contact_info: {
|
||||
email: string;
|
||||
phone: string;
|
||||
};
|
||||
similarity?: number;
|
||||
scores?: CandidateScore[];
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const supabase = createServerSupabaseClient();
|
||||
const jobId = request.nextUrl.searchParams.get("jobId");
|
||||
|
||||
if (jobId) {
|
||||
// 1. Fetch the job embedding
|
||||
const { data: job, error: jobError } = await supabase
|
||||
.from("jobs")
|
||||
.select("embedding")
|
||||
.eq("id", jobId)
|
||||
.single();
|
||||
|
||||
if (jobError || !job) {
|
||||
return NextResponse.json({ error: "Job not found or error fetching job" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!job.embedding) {
|
||||
return NextResponse.json({ error: "Job embedding not generated yet" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 2. Query similarity ranking using match_candidates rpc
|
||||
const { data: rankedCandidates, error: matchError } = await supabase.rpc(
|
||||
"match_candidates",
|
||||
{
|
||||
query_embedding: job.embedding,
|
||||
match_threshold: -1.0,
|
||||
match_count: 50,
|
||||
}
|
||||
);
|
||||
|
||||
if (matchError) {
|
||||
return NextResponse.json({ error: matchError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const candidatesList = (rankedCandidates as unknown as RankedCandidate[]) || [];
|
||||
|
||||
// 3. Fetch scores for these matched candidates to return AI scores/details
|
||||
if (candidatesList.length > 0) {
|
||||
const candidateIds = candidatesList.map((c) => c.id);
|
||||
const { data: scores, error: scoresError } = await supabase
|
||||
.from("scores")
|
||||
.select("*")
|
||||
.in("candidate_id", candidateIds);
|
||||
|
||||
if (!scoresError && scores) {
|
||||
const typedScores = (scores as unknown as CandidateScore[]) || [];
|
||||
// Merge scores into rankedCandidates
|
||||
const scoresMap = new Map<string, CandidateScore[]>();
|
||||
typedScores.forEach((s) => {
|
||||
const list = scoresMap.get(s.candidate_id) || [];
|
||||
list.push(s);
|
||||
scoresMap.set(s.candidate_id, list);
|
||||
});
|
||||
|
||||
candidatesList.forEach((c) => {
|
||||
c.scores = scoresMap.get(c.id) || [];
|
||||
});
|
||||
} else {
|
||||
candidatesList.forEach((c) => {
|
||||
c.scores = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(candidatesList);
|
||||
} else {
|
||||
// Fetch all candidates sorted by created_at descending
|
||||
const { data: candidates, error } = await supabase
|
||||
.from("candidates")
|
||||
.select("*, scores(*)")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(candidates);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Internal Server Error";
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
55
app/api/jobs/route.ts
Normal file
55
app/api/jobs/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase";
|
||||
import { generateEmbedding } from "@/lib/embeddings";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = createServerSupabaseClient();
|
||||
const { data: jobs, error } = await supabase
|
||||
.from("jobs")
|
||||
.select("*")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(jobs);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Internal Server Error";
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { title, requirements } = body;
|
||||
|
||||
if (!title || !requirements) {
|
||||
return NextResponse.json({ error: "Title and requirements are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const embedding = await generateEmbedding(requirements);
|
||||
const supabase = createServerSupabaseClient();
|
||||
|
||||
const { data: job, error } = await supabase
|
||||
.from("jobs")
|
||||
.insert({
|
||||
title,
|
||||
requirements: { text: requirements },
|
||||
embedding,
|
||||
})
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(job);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Internal Server Error";
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
57
lib/embeddings.ts
Normal file
57
lib/embeddings.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Logger } from "./logger";
|
||||
|
||||
export async function generateEmbedding(text: string): Promise<number[]> {
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("Missing GEMINI_API_KEY environment variable");
|
||||
}
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const response = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=${apiKey}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "models/text-embedding-004",
|
||||
content: {
|
||||
parts: [{ text }],
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(`Gemini embedding API error: ${response.status} - ${errText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const embedding = data.embedding?.values;
|
||||
|
||||
if (!Array.isArray(embedding)) {
|
||||
throw new Error("Invalid embedding response structure from Gemini API");
|
||||
}
|
||||
|
||||
Logger.info("Generated Gemini embedding successfully", {
|
||||
textLength: text.length,
|
||||
originalDimension: embedding.length,
|
||||
}, Date.now() - start);
|
||||
|
||||
// Gemini text-embedding-004 outputs 768 dimensions.
|
||||
// Pad with zeros to fit database vector(1536) schema limit.
|
||||
const targetDimension = 1536;
|
||||
const paddedEmbedding = [...embedding];
|
||||
while (paddedEmbedding.length < targetDimension) {
|
||||
paddedEmbedding.push(0.0);
|
||||
}
|
||||
|
||||
return paddedEmbedding;
|
||||
} catch (error) {
|
||||
Logger.error("Failed to generate embedding", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
317
scripts/deploy-n8n-v2.ts
Normal file
317
scripts/deploy-n8n-v2.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// Load .env variables
|
||||
const envPath = path.join(__dirname, "../.env");
|
||||
if (fs.existsSync(envPath)) {
|
||||
const envContent = fs.readFileSync(envPath, "utf8");
|
||||
for (const line of envContent.split("\n")) {
|
||||
const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/);
|
||||
if (match) {
|
||||
const key = match[1];
|
||||
let value = match[2].trim();
|
||||
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
|
||||
else if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const N8N_HOST = process.env.N8N_HOST || "https://n8n.gaboggamer.online";
|
||||
const N8N_API_KEY = process.env.N8N_API_KEY;
|
||||
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const SUPABASE_SECRET_KEY = process.env.SUPABASE_SECRET_KEY;
|
||||
|
||||
if (!N8N_API_KEY) {
|
||||
console.error("Error: N8N_API_KEY is not defined in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function n8nRequest(endpoint: string, method: string = "GET", body?: any) {
|
||||
const response = await fetch(`${N8N_HOST}${endpoint}`, {
|
||||
method,
|
||||
headers: {
|
||||
"X-N8N-API-KEY": N8N_API_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`n8n API request failed: ${response.status} ${response.statusText} - ${text}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function getOrCreateCredential(name: string, type: string, data: any) {
|
||||
try {
|
||||
const credsList = await n8nRequest("/api/v1/credentials");
|
||||
const existingCred = credsList.data.find((c: any) => c.name === name && c.type === type);
|
||||
if (existingCred) {
|
||||
console.log(`Reusing existing credential: ${name} (ID: ${existingCred.id})`);
|
||||
return existingCred.id;
|
||||
} else {
|
||||
const newCred = await n8nRequest("/api/v1/credentials", "POST", {
|
||||
name,
|
||||
type,
|
||||
data,
|
||||
});
|
||||
console.log(`Created new credential: ${name} (ID: ${newCred.id})`);
|
||||
return newCred.id;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`Error setting up credential ${name}:`, err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Starting n8n Candidate Evaluation Flow deployment...");
|
||||
|
||||
// 1. Create/Retrieve Supabase credential
|
||||
console.log("Checking Supabase credentials...");
|
||||
const supabaseCredId = await getOrCreateCredential("Semillero2_Supabase_V2", "supabaseApi", {
|
||||
host: SUPABASE_URL,
|
||||
serviceRole: SUPABASE_SECRET_KEY,
|
||||
allowedHttpRequestDomains: "none",
|
||||
});
|
||||
|
||||
// 2. Create/Retrieve Gemini credential
|
||||
console.log("Checking Gemini credentials...");
|
||||
const geminiCredId = await getOrCreateCredential("Semillero2_Gemini_V2", "googlePalmApi", {
|
||||
apiKey: GEMINI_API_KEY,
|
||||
host: "https://generativelanguage.googleapis.com",
|
||||
allowedHttpRequestDomains: "none",
|
||||
});
|
||||
|
||||
// 3. Define the E2E Candidate Evaluation workflow
|
||||
const workflowDefinition = {
|
||||
name: "Semillero2: End-to-End Candidate Evaluation",
|
||||
settings: {},
|
||||
nodes: [
|
||||
{
|
||||
parameters: {
|
||||
httpMethod: "POST",
|
||||
path: "evaluate-candidate",
|
||||
responseMode: "responseNode",
|
||||
options: {},
|
||||
},
|
||||
id: "webhook-trigger",
|
||||
name: "Webhook Trigger",
|
||||
type: "n8n-nodes-base.webhook",
|
||||
typeVersion: 1.1,
|
||||
position: [100, 300],
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
promptType: "Define below",
|
||||
text: "={{ $json.body.text }}",
|
||||
systemMessage: "You are an AI recruitment assistant evaluating a candidate's CV for a job vacancy. Analyze the candidate's CV text. You MUST respond with a raw JSON object containing exactly these five keys:\n- summary: a brief candidate summary (max 3 sentences).\n- classification: 'Qualified', 'Unqualified', or 'Review'.\n- suggestions: an array of recommendations for next steps (e.g. ['Schedule interview', 'Reject', 'Verify references']).\n- riskLevel: 'Low', 'Medium', or 'High'.\n- ai_score: a number between 0 and 100 representing general suitability.\n\nDo not include markdown code blocks or any text outside the JSON.",
|
||||
},
|
||||
id: "llm-chain",
|
||||
name: "LLM Chain Evaluation",
|
||||
type: "@n8n/n8n-nodes-langchain.chainLlm",
|
||||
typeVersion: 1.4,
|
||||
position: [350, 300],
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
model: "gemini-1.5-flash",
|
||||
options: {},
|
||||
},
|
||||
id: "gemini-model",
|
||||
name: "Gemini Chat Model",
|
||||
type: "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
|
||||
typeVersion: 1,
|
||||
position: [300, 480],
|
||||
credentials: {
|
||||
googlePalmApi: {
|
||||
id: geminiCredId,
|
||||
name: "Semillero2_Gemini_V2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
jsonSchema: "{\n \"type\": \"object\",\n \"properties\": {\n \"summary\": {\n \"type\": \"string\"\n },\n \"classification\": {\n \"type\": \"string\",\n \"enum\": [\"Qualified\", \"Unqualified\", \"Review\"]\n },\n \"suggestions\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"riskLevel\": {\n \"type\": \"string\",\n \"enum\": [\"Low\", \"Medium\", \"High\"]\n },\n \"ai_score\": {\n \"type\": \"number\"\n }\n },\n \"required\": [\"summary\", \"classification\", \"suggestions\", \"riskLevel\", \"ai_score\"]\n}",
|
||||
},
|
||||
id: "json-parser",
|
||||
name: "Structured Output Parser",
|
||||
type: "@n8n/n8n-nodes-langchain.outputParserStructured",
|
||||
typeVersion: 1,
|
||||
position: [460, 480],
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
jsCode: `const input = $input.first().json;
|
||||
const webhookData = $('Webhook Trigger').first().json.body;
|
||||
return [{
|
||||
json: {
|
||||
candidate_id: webhookData.candidateId,
|
||||
interview_id: webhookData.interviewId,
|
||||
ai_score: input.ai_score,
|
||||
evaluation: {
|
||||
summary: input.summary,
|
||||
classification: input.classification,
|
||||
suggestions: input.suggestions,
|
||||
riskLevel: input.riskLevel
|
||||
}
|
||||
}
|
||||
}];`,
|
||||
},
|
||||
id: "format-data",
|
||||
name: "Format Evaluation Data",
|
||||
type: "n8n-nodes-base.code",
|
||||
typeVersion: 2,
|
||||
position: [600, 300],
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
operation: "insert",
|
||||
table: "scores",
|
||||
options: {},
|
||||
},
|
||||
id: "supabase-insert",
|
||||
name: "Insert Score to Supabase",
|
||||
type: "n8n-nodes-base.supabase",
|
||||
typeVersion: 1,
|
||||
position: [800, 300],
|
||||
credentials: {
|
||||
supabaseApi: {
|
||||
id: supabaseCredId,
|
||||
name: "Semillero2_Supabase_V2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
options: {},
|
||||
},
|
||||
id: "respond-webhook",
|
||||
name: "Respond to Webhook",
|
||||
type: "n8n-nodes-base.respondToWebhook",
|
||||
typeVersion: 1.1,
|
||||
position: [1000, 300],
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
"Webhook Trigger": {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: "LLM Chain Evaluation",
|
||||
type: "main",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
"Gemini Chat Model": {
|
||||
ai_languageModel: [
|
||||
[
|
||||
{
|
||||
node: "LLM Chain Evaluation",
|
||||
type: "ai_languageModel",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
"Structured Output Parser": {
|
||||
outputParser: [
|
||||
[
|
||||
{
|
||||
node: "LLM Chain Evaluation",
|
||||
type: "outputParser",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
"LLM Chain Evaluation": {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: "Format Evaluation Data",
|
||||
type: "main",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
"Format Evaluation Data": {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: "Insert Score to Supabase",
|
||||
type: "main",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
"Insert Score to Supabase": {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: "Respond to Webhook",
|
||||
type: "main",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
console.log("Deploying workflow to n8n...");
|
||||
// Check if it already exists
|
||||
const workflowsList = await n8nRequest("/api/v1/workflows");
|
||||
const existingWf = workflowsList.data.find(
|
||||
(w: any) => w.name === "Semillero2: End-to-End Candidate Evaluation"
|
||||
);
|
||||
|
||||
let deployResult;
|
||||
if (existingWf) {
|
||||
console.log(`Updating existing workflow (ID: ${existingWf.id})...`);
|
||||
deployResult = await n8nRequest(`/api/v1/workflows/${existingWf.id}`, "PUT", workflowDefinition);
|
||||
} else {
|
||||
deployResult = await n8nRequest("/api/v1/workflows", "POST", workflowDefinition);
|
||||
}
|
||||
|
||||
// Activate the workflow
|
||||
console.log(`Activating workflow (ID: ${deployResult.id})...`);
|
||||
await n8nRequest(`/api/v1/workflows/${deployResult.id}/activate`, "POST");
|
||||
|
||||
const webhookUrl = `${N8N_HOST}/webhook/${deployResult.id}/webhook/evaluate-candidate`;
|
||||
console.log("\n==============================================");
|
||||
console.log("DEPLOYMENT COMPLETE");
|
||||
console.log("==============================================");
|
||||
console.log(`Workflow ID: ${deployResult.id}`);
|
||||
console.log(`Webhook URL: ${webhookUrl}`);
|
||||
console.log("==============================================");
|
||||
|
||||
// Update .env file automatically
|
||||
const envFilePath = path.join(__dirname, "../.env");
|
||||
if (fs.existsSync(envFilePath)) {
|
||||
let envContent = fs.readFileSync(envFilePath, "utf8");
|
||||
if (envContent.includes("NEXT_PUBLIC_N8N_WEBHOOK_URL=")) {
|
||||
envContent = envContent.replace(
|
||||
/NEXT_PUBLIC_N8N_WEBHOOK_URL=.*/,
|
||||
`NEXT_PUBLIC_N8N_WEBHOOK_URL=${webhookUrl}`
|
||||
);
|
||||
} else {
|
||||
envContent += `\nNEXT_PUBLIC_N8N_WEBHOOK_URL=${webhookUrl}\n`;
|
||||
}
|
||||
fs.writeFileSync(envFilePath, envContent, "utf8");
|
||||
console.log("Updated NEXT_PUBLIC_N8N_WEBHOOK_URL in .env");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Deployment failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in a new issue