feat(flow): implement e2e flow with n8n and db

This commit is contained in:
Gabriel Ramos 2026-06-09 10:25:06 -04:00
parent f663d04e10
commit b751ee556f
9 changed files with 1540 additions and 65 deletions

View file

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

View file

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

View file

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

View file

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

View file

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