From b751ee556f5b91ce6f94e8a321888b9840166f3e Mon Sep 17 00:00:00 2001 From: Gabriel Ramos Date: Tue, 9 Jun 2026 10:25:06 -0400 Subject: [PATCH] feat(flow): implement e2e flow with n8n and db --- .../candidates/api/parse-cv/route.ts | 163 ++++--- app/(dashboard)/candidates/page.tsx | 168 +++++++- app/(dashboard)/interviews/page.tsx | 229 +++++++++- app/(dashboard)/jobs/page.tsx | 405 +++++++++++++++++- app/(dashboard)/workflows/page.tsx | 100 ++++- app/api/candidates/route.ts | 111 +++++ app/api/jobs/route.ts | 55 +++ lib/embeddings.ts | 57 +++ scripts/deploy-n8n-v2.ts | 317 ++++++++++++++ 9 files changed, 1540 insertions(+), 65 deletions(-) create mode 100644 app/api/candidates/route.ts create mode 100644 app/api/jobs/route.ts create mode 100644 lib/embeddings.ts create mode 100644 scripts/deploy-n8n-v2.ts diff --git a/app/(dashboard)/candidates/api/parse-cv/route.ts b/app/(dashboard)/candidates/api/parse-cv/route.ts index 044a05e..9e5eeda 100644 --- a/app/(dashboard)/candidates/api/parse-cv/route.ts +++ b/app/(dashboard)/candidates/api/parse-cv/route.ts @@ -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); diff --git a/app/(dashboard)/candidates/page.tsx b/app/(dashboard)/candidates/page.tsx index ab00b4c..78996e7 100644 --- a/app/(dashboard)/candidates/page.tsx +++ b/app/(dashboard)/candidates/page.tsx @@ -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([]); + 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 ( -
-

Candidates

-

View and evaluate parsed candidate profiles.

+
+
+

Candidates

+

+ A list of all candidates parsed and analyzed by the AI recruitment pipeline. +

+
+ + {loading ? ( +
+

Loading candidates...

+
+ ) : candidates.length === 0 ? ( +
+

+ No candidates found. Upload CVs on the Jobs tab to parse them. +

+
+ ) : ( +
+ {candidates.map((candidate) => { + // Get the latest score + const latestScore = + candidate.scores && candidate.scores.length > 0 + ? candidate.scores[0] + : null; + + return ( +
+ {/* Candidate Info Header */} +
+
+

+ {candidate.name} +

+
+ Email:{" "} + + {candidate.contact_info.email} + + | + Phone:{" "} + + {candidate.contact_info.phone} + +
+
+ {latestScore ? ( +
+
+ + AI Assessment + + + {latestScore.ai_score} / 10 + +
+
+ {latestScore.evaluation.classification} +
+
+ ) : ( +
+ Pending Evaluation +
+ )} +
+ + {/* Score details if available */} + {latestScore ? ( +
+
+ + AI Summary + +

+ {latestScore.evaluation.summary} +

+
+ Risk Level:{" "} + + {latestScore.evaluation.riskLevel} + +
+
+
+ + Action Items / Suggestions + +

+ {latestScore.evaluation.suggestions} +

+
+
+ ) : ( +

+ This candidate's CV has been indexed, but the AI evaluation + has not yet completed. The background n8n workflow updates + scores upon completion. +

+ )} +
+ ); + })} +
+ )}
); } diff --git a/app/(dashboard)/interviews/page.tsx b/app/(dashboard)/interviews/page.tsx index 33c6a37..adc0583 100644 --- a/app/(dashboard)/interviews/page.tsx +++ b/app/(dashboard)/interviews/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [updatingId, setUpdatingId] = useState(null); + const [editStates, setEditStates] = useState< + Record + >({}); + const [actionMessage, setActionMessage] = useState(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 = {}; + 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 ( -
-

Interviews

-

Schedule and monitor candidate evaluations.

+
+
+
+

Interviews

+

+ Manage scheduled candidate interview stages and write evaluation feedback. +

+
+ {actionMessage && ( +
+ {actionMessage} +
+ )} +
+ + {loading ? ( +
+

Loading interviews...

+
+ ) : interviews.length === 0 ? ( +
+

+ No interviews scheduled. Upload candidate CVs under the Jobs tab to trigger evaluations. +

+
+ ) : ( +
+ {interviews.map((interview) => { + const currentEdit = editStates[interview.id] || { + stage: interview.stage, + feedback: interview.feedback || "", + }; + + return ( +
+ {/* Header */} +
+
+

+ {interview.candidates?.name || "Unknown Candidate"} +

+

+ Role: {interview.jobs?.title || "Unknown Job"} +

+

+ Date Scheduled:{" "} + {new Date(interview.interview_date).toLocaleString()} +

+
+
+ + +
+
+ + {/* Feedback Area */} +
+ +