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,18 +1,22 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { createServerSupabaseClient } from "@/lib/supabase";
|
||||||
|
import { generateEmbedding } from "@/lib/embeddings";
|
||||||
import { PDFParse } from "pdf-parse";
|
import { PDFParse } from "pdf-parse";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
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) {
|
if (!file) {
|
||||||
return NextResponse.json({ error: "No files uploaded" }, { status: 400 });
|
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 arrayBuffer = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
|
@ -22,59 +26,112 @@ export async function POST(request: NextRequest) {
|
||||||
const text = pdfData.text;
|
const text = pdfData.text;
|
||||||
await parser.destroy();
|
await parser.destroy();
|
||||||
|
|
||||||
// Extract candidate name from file name (strip extension)
|
if (!text) {
|
||||||
|
return NextResponse.json({ error: "Failed to extract text from PDF" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean text
|
||||||
|
const cleanText = text.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
// Extract name from file (strip extension)
|
||||||
const name = file.name.replace(/\.[^/.]+$/, "");
|
const name = file.name.replace(/\.[^/.]+$/, "");
|
||||||
|
|
||||||
// Extract email using basic regex
|
// Extract email and phone using regex
|
||||||
const emailRegex = /[\w.-]+@[\w.-]+\.\w+/;
|
const emailRegex = /[\w.-]+@[\w.-]+\.\w+/;
|
||||||
const emailMatch = text.match(emailRegex);
|
const emailMatch = text.match(emailRegex);
|
||||||
const email = emailMatch ? emailMatch[0] : "unknown@example.com";
|
const email = emailMatch ? emailMatch[0] : "unknown@example.com";
|
||||||
|
|
||||||
candidates.push({
|
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,
|
name,
|
||||||
email,
|
contact_info: { email, phone },
|
||||||
text,
|
embedding,
|
||||||
});
|
})
|
||||||
|
.select("*")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (candidateError || !candidate) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: candidateError?.message || "Failed to insert candidate" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
const webhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
|
||||||
if (!webhookUrl) {
|
if (webhookUrl) {
|
||||||
return NextResponse.json({ error: "Webhook URL not configured" }, { status: 500 });
|
try {
|
||||||
}
|
|
||||||
|
|
||||||
// Send the array of candidates to n8n Webhook
|
|
||||||
const n8nResponse = await fetch(webhookUrl, {
|
const n8nResponse = await fetch(webhookUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
source: "web",
|
candidateId: candidate.id,
|
||||||
candidates,
|
interviewId: interview.id,
|
||||||
|
candidateName: candidate.name,
|
||||||
|
candidateEmail: email,
|
||||||
|
text: cleanText,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!n8nResponse.ok) {
|
if (n8nResponse.ok) {
|
||||||
const errText = await n8nResponse.text();
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `n8n webhook call failed: ${n8nResponse.status} - ${errText}` },
|
|
||||||
{ status: 502 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if response has content
|
|
||||||
let responseData = null;
|
|
||||||
const contentType = n8nResponse.headers.get("content-type");
|
const contentType = n8nResponse.headers.get("content-type");
|
||||||
if (contentType && contentType.includes("application/json")) {
|
if (contentType && contentType.includes("application/json")) {
|
||||||
responseData = await n8nResponse.json();
|
n8nResponseData = await n8nResponse.json();
|
||||||
} else {
|
} else {
|
||||||
responseData = { message: await n8nResponse.text() };
|
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 {
|
||||||
|
n8nResponseData = { message: "NEXT_PUBLIC_N8N_WEBHOOK_URL is not set" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "CVs processed and forwarded to n8n successfully",
|
candidateId: candidate.id,
|
||||||
data: responseData,
|
interviewId: interview.id,
|
||||||
|
candidateName: candidate.name,
|
||||||
|
candidateEmail: email,
|
||||||
|
n8nResponse: n8nResponseData,
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error("Error in parse-cv route:", error);
|
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() {
|
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 (
|
return (
|
||||||
|
<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">
|
<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-500 text-sm">Loading candidates...</p>
|
||||||
<p className="text-slate-600 text-sm">View and evaluate parsed candidate profiles.</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>
|
</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() {
|
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 (
|
return (
|
||||||
|
<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">
|
<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-500 text-sm">Loading interviews...</p>
|
||||||
<p className="text-slate-600 text-sm">Schedule and monitor candidate evaluations.</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>
|
</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() {
|
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 (
|
return (
|
||||||
|
<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">
|
<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>
|
<h2 className="text-lg font-bold text-slate-900 mb-4">Create Vacancy</h2>
|
||||||
<p className="text-slate-600 text-sm">Manage job vacancies and candidate matches.</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,102 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
export default function WorkflowsPage() {
|
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 (
|
return (
|
||||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-200">
|
<div className="flex flex-col gap-6 max-w-3xl">
|
||||||
<h1 className="text-xl font-bold text-slate-900 mb-2">Workflows</h1>
|
<div>
|
||||||
<p className="text-slate-600 text-sm">Configure automated recruitment pipelines orchestrated by n8n.</p>
|
<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>
|
</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