feat(candidates): scale UI, bulk upload & AI diffs

This commit is contained in:
Gabriel Ramos 2026-06-10 14:18:25 -04:00
parent aa028ec263
commit 1a439e3bd0
7 changed files with 1288 additions and 195 deletions

View file

@ -12,3 +12,15 @@ A modern ATS platform designed to parse PDF CVs using multimodal AI, rank candid
- **Recruiter - Stage Automation:** As a recruiter, I want candidates to move through recruitment stages automatically.
- **Candidate - Automated Emails:** As a candidate, I want to receive automated email confirmations for every stage change.
- **Talent Team - Vacancy Metrics:** As a talent team, we want metrics on the progress per vacancy.
## Setup & Prerequisites
### 1. Google AI Studio Account (Mandatory)
Vector embeddings matching and search operations require a direct call to the Google Gemini Embeddings API (`models/gemini-embedding-001`).
* **Prerequisite**: You must obtain a free-tier or paid-tier Gemini API key from [Google AI Studio](https://aistudio.google.com/).
* **Usage**: The embedding model is free for up to 1,500 requests per day (15 requests per minute), which covers standard development and testing requirements.
* **Configuration**: Add your key to the `.env` file at the root of the project:
```env
GEMINI_API_KEY=your_google_ai_studio_api_key_here
```

View file

@ -35,45 +35,151 @@ export async function POST(request: NextRequest) {
const embedding = await generateEmbedding(cleanText);
const isTest = formData.get("isTest") === "true";
const duplicateAction = formData.get("duplicateAction") || "check";
let candidateId = "00000000-0000-0000-0000-000000000000";
let candidateName = profile.candidateName;
let isDuplicate = false;
let existingCandidateData = null;
if (!isTest) {
// Initialize Supabase admin client
const supabase = createServerSupabaseClient();
// Insert candidate with extracted details (including skills, summary, and cv_text)
const { data: candidate, error: candidateError } = await supabase
.from("candidates")
.insert({
name: profile.candidateName,
contact_info: {
email: profile.email,
phone: profile.phone,
skills: profile.skills || [],
summary: profile.summary || "",
cv_text: cleanText,
},
embedding,
})
.select("*")
.single();
if (candidateError || !candidate) {
return NextResponse.json(
{ error: candidateError?.message || "Failed to insert candidate" },
{ status: 500 }
);
interface DbCandidate {
id: string;
name: string;
contact_info: {
email: string;
phone: string;
skills?: string[];
summary?: string;
};
}
candidateId = candidate.id;
candidateName = candidate.name;
// Check for duplicate candidate (by email or exact name match)
let existingCandidate: DbCandidate | null = null;
if (profile.email) {
const { data } = await supabase
.from("candidates")
.select("*")
.eq("contact_info->>email", profile.email)
.maybeSingle();
existingCandidate = data as DbCandidate | null;
}
// Decoupled: We no longer create an initial interview record on upload.
// Interviews are only queued when recruiter manually takes action.
if (!existingCandidate && profile.candidateName) {
const { data } = await supabase
.from("candidates")
.select("*")
.ilike("name", profile.candidateName)
.maybeSingle();
existingCandidate = data as DbCandidate | null;
}
if (existingCandidate) {
if (duplicateAction === "check") {
isDuplicate = true;
existingCandidateData = {
id: existingCandidate.id,
name: existingCandidate.name,
contact_info: existingCandidate.contact_info,
};
} else if (duplicateAction === "overwrite") {
const { data: updated, error: updateError } = await supabase
.from("candidates")
.update({
name: profile.candidateName,
contact_info: {
email: profile.email,
phone: profile.phone,
skills: profile.skills || [],
summary: profile.summary || "",
cv_text: cleanText,
},
embedding,
})
.eq("id", existingCandidate.id)
.select("*")
.single();
if (updateError || !updated) {
return NextResponse.json(
{ error: updateError?.message || "Failed to overwrite candidate" },
{ status: 500 }
);
}
candidateId = updated.id;
candidateName = updated.name;
} else {
// ignore: insert as new candidate
const { data: candidate, error: candidateError } = await supabase
.from("candidates")
.insert({
name: profile.candidateName,
contact_info: {
email: profile.email,
phone: profile.phone,
skills: profile.skills || [],
summary: profile.summary || "",
cv_text: cleanText,
},
embedding,
})
.select("*")
.single();
if (candidateError || !candidate) {
return NextResponse.json(
{ error: candidateError?.message || "Failed to insert candidate" },
{ status: 500 }
);
}
candidateId = candidate.id;
candidateName = candidate.name;
}
} else {
// No duplicate found, insert new candidate
const { data: candidate, error: candidateError } = await supabase
.from("candidates")
.insert({
name: profile.candidateName,
contact_info: {
email: profile.email,
phone: profile.phone,
skills: profile.skills || [],
summary: profile.summary || "",
cv_text: cleanText,
},
embedding,
})
.select("*")
.single();
if (candidateError || !candidate) {
return NextResponse.json(
{ error: candidateError?.message || "Failed to insert candidate" },
{ status: 500 }
);
}
candidateId = candidate.id;
candidateName = candidate.name;
}
}
if (isDuplicate) {
return NextResponse.json({
success: true,
isDuplicate: true,
existingCandidate: existingCandidateData,
newProfile: profile,
});
}
return NextResponse.json({
success: true,
candidateId,

File diff suppressed because it is too large Load diff

View file

@ -75,6 +75,33 @@ interface UploadFileStatus {
errorMessage?: string;
}
interface DuplicateState {
fileName: string;
existingCandidate: {
id: string;
name: string;
contact_info: {
email: string;
phone: string;
skills?: string[];
summary?: string;
};
};
newProfile: {
candidateName?: string;
name?: string;
email?: string;
phone?: string;
skills?: string[];
summary?: string;
};
comparison: {
en: string;
es: string;
} | null;
onResolve: (action: "overwrite" | "ignore" | "cancel") => void;
}
export default function JobsPage() {
const [jobs, setJobs] = useState<Job[]>([]);
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
@ -86,6 +113,7 @@ export default function JobsPage() {
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const [uploadStatuses, setUploadStatuses] = useState<UploadFileStatus[]>([]);
const [duplicateData, setDuplicateData] = useState<DuplicateState | null>(null);
// Evaluation states
const [evaluatingIds, setEvaluatingIds] = useState<Record<string, boolean>>({});
@ -222,54 +250,114 @@ export default function JobsPage() {
setUploadError(null);
setUploadSuccess(null);
const uploadPromises = fileList.map(async (file, index) => {
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
if (file.type !== "application/pdf") {
setUploadStatuses((prev) =>
prev.map((status, idx) =>
idx === index
idx === i
? { ...status, status: "error" as const, errorMessage: "Only PDF files are allowed" }
: status
)
);
return;
continue;
}
try {
const formData = new FormData();
formData.append("file", file);
formData.append("jobId", selectedJob.id);
let currentAction = "check";
let done = false;
const res = await fetch("/candidates/api/parse-cv", {
method: "POST",
body: formData,
});
while (!done) {
try {
const formData = new FormData();
formData.append("file", file);
formData.append("duplicateAction", currentAction);
formData.append("jobId", selectedJob.id);
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || "Failed to process CV");
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");
}
const data = await res.json();
if (data.isDuplicate) {
// Trigger AI Comparison summary
let comparisonResult = null;
try {
const compRes = await fetch("/api/candidates/compare", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
existingProfile: data.existingCandidate,
newProfile: data.newProfile,
}),
});
if (compRes.ok) {
const compData = await compRes.json();
comparisonResult = compData.comparison;
}
} catch (compErr) {
console.error("Comparison request failed:", compErr);
}
// Pause and wait for user's decision
const userAction = await new Promise<"overwrite" | "ignore" | "cancel">((resolve) => {
setDuplicateData({
fileName: file.name,
existingCandidate: data.existingCandidate,
newProfile: data.newProfile,
comparison: comparisonResult,
onResolve: resolve,
});
});
// Close dialog
setDuplicateData(null);
if (userAction === "cancel") {
setUploadStatuses((prev) =>
prev.map((status, idx) =>
idx === i
? { ...status, status: "error" as const, errorMessage: "Upload cancelled by user" }
: status
)
);
done = true;
} else {
// Resend request with overwrite or ignore parameter
currentAction = userAction === "overwrite" ? "overwrite" : "ignore";
}
} else {
// Success
setUploadStatuses((prev) =>
prev.map((status, idx) =>
idx === i
? { ...status, status: "success" as const }
: status
)
);
done = true;
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Error uploading CV";
setUploadStatuses((prev) =>
prev.map((status, idx) =>
idx === i
? { ...status, status: "error" as const, errorMessage: msg }
: status
)
);
done = true;
}
await res.json();
setUploadStatuses((prev) =>
prev.map((status, idx) =>
idx === index
? { ...status, status: "success" as const }
: status
)
);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Error uploading CV";
setUploadStatuses((prev) =>
prev.map((status, idx) =>
idx === index
? { ...status, status: "error" as const, errorMessage: msg }
: status
)
);
}
});
}
await Promise.all(uploadPromises);
setUploading(false);
// Refresh matches for current job
@ -901,6 +989,105 @@ export default function JobsPage() {
</div>
)}
</div>
{/* Duplicate Detection dialog */}
{duplicateData && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
<div className="bg-white rounded-lg shadow-md border border-slate-200 max-w-xl w-full p-6 flex flex-col gap-4 max-h-[90vh] overflow-y-auto">
<div>
<h3 className="text-lg font-bold text-slate-900">
Duplicate Candidate Detected / Candidato Duplicado Detectado
</h3>
<p className="text-xs text-slate-500 mt-1">
The system detected an existing candidate with the same email or name. / El sistema detectó un candidato existente con el mismo correo o nombre. ({duplicateData.fileName})
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
{/* Existing Profile */}
<div className="border border-slate-200 rounded-md p-3 bg-slate-50">
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2">
Existing Profile / Perfil Existente
</h4>
<div className="text-sm font-bold text-slate-900">
{duplicateData.existingCandidate.name}
</div>
<div className="text-xs text-slate-500 mt-1">
Email: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.email}</span>
</div>
<div className="text-xs text-slate-500">
Phone: <span className="text-slate-600 font-medium">{duplicateData.existingCandidate.contact_info.phone || "N/A"}</span>
</div>
{duplicateData.existingCandidate.contact_info.summary && (
<p className="text-slate-600 mt-2 line-clamp-3">
{duplicateData.existingCandidate.contact_info.summary}
</p>
)}
</div>
{/* New Profile */}
<div className="border border-slate-200 rounded-md p-3 bg-slate-50">
<h4 className="text-xs font-semibold text-slate-900 uppercase tracking-wider mb-2">
Newly Uploaded Profile / Nuevo Perfil Cargado
</h4>
<div className="text-sm font-bold text-slate-900">
{duplicateData.newProfile.candidateName || duplicateData.newProfile.name || "Unknown"}
</div>
<div className="text-xs text-slate-500 mt-1">
Email: <span className="text-slate-600 font-medium">{duplicateData.newProfile.email || "N/A"}</span>
</div>
<div className="text-xs text-slate-500">
Phone: <span className="text-slate-600 font-medium">{duplicateData.newProfile.phone || "N/A"}</span>
</div>
{duplicateData.newProfile.summary && (
<p className="text-slate-600 mt-2 line-clamp-3">
{duplicateData.newProfile.summary}
</p>
)}
</div>
</div>
{/* AI Comparison Summary */}
<div className="border border-slate-200 rounded-md p-3 bg-blue-50">
<h4 className="text-xs font-semibold text-blue-600 uppercase tracking-wider mb-2">
AI Comparison Summary / Resumen de Comparación de IA
</h4>
{duplicateData.comparison ? (
<div className="text-xs text-slate-600 leading-relaxed flex flex-col gap-2">
<p><strong>EN:</strong> {duplicateData.comparison.en}</p>
<p><strong>ES:</strong> {duplicateData.comparison.es}</p>
</div>
) : (
<p className="text-xs text-slate-500 italic">
Comparing profiles with AI... / Comparando perfiles con IA...
</p>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t border-slate-200">
<button
onClick={() => duplicateData.onResolve("cancel")}
className="px-3 py-1.5 border border-slate-200 rounded-md text-xs text-slate-600 bg-white hover:bg-slate-50 transition"
>
Cancel / Cancelar
</button>
<button
onClick={() => duplicateData.onResolve("ignore")}
className="px-3 py-1.5 bg-slate-600 hover:bg-slate-700 text-white rounded-md text-xs font-semibold transition"
>
Keep Both / Conservar ambos
</button>
<button
onClick={() => duplicateData.onResolve("overwrite")}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-xs font-semibold transition"
>
Overwrite / Sobrescribir
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "Missing GEMINI_API_KEY environment variable" }, { status: 500 });
}
const body = await request.json();
const { existingProfile, newProfile } = body;
if (!existingProfile || !newProfile) {
return NextResponse.json({ error: "existingProfile and newProfile are required" }, { status: 400 });
}
const prompt = `You are an AI recruitment assistant. Compare the existing candidate profile against the newly uploaded CV profile for a candidate.
Analyze differences in skills, experience, and summary.
Determine:
1. If they appear to be the same person (updated resume) or two different people with the same name.
2. What new skills or experiences are present in the new profile compared to the old one.
Existing Profile:
Name: ${existingProfile.name}
Skills: ${JSON.stringify(existingProfile.skills || existingProfile.contact_info?.skills || [])}
Summary: ${existingProfile.summary || existingProfile.contact_info?.summary || ""}
New Profile:
Name: ${newProfile.candidateName || newProfile.name}
Skills: ${JSON.stringify(newProfile.skills || newProfile.contact_info?.skills || [])}
Summary: ${newProfile.summary || newProfile.contact_info?.summary || ""}
You MUST respond with a raw JSON object containing exactly these two keys:
- en: A concise 2-3 sentence summary of the differences in English.
- es: A concise 2-3 sentence summary of the differences in Spanish.
Do not include any markdown formatting, code blocks, or text outside the JSON.`;
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
contents: [{
parts: [{ text: prompt }]
}],
generationConfig: {
responseMimeType: "application/json",
}
}),
}
);
if (!response.ok) {
const errText = await response.text();
return NextResponse.json({ error: `Gemini API error: ${response.status} - ${errText}` }, { status: 500 });
}
const data = await response.json();
const textContent = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!textContent) {
return NextResponse.json({ error: "Failed to generate comparison from Gemini" }, { status: 500 });
}
const parsedComparison = JSON.parse(textContent.trim());
return NextResponse.json({
success: true,
comparison: parsedComparison,
});
} catch (error: unknown) {
console.error("Error in compare API:", error);
const errorMessage = error instanceof Error ? error.message : "Internal Server Error";
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}

View file

@ -12,6 +12,10 @@ interface CandidateScore {
riskLevel: string;
};
created_at: string;
job_id?: string;
jobs?: {
title: string;
};
}
interface InterviewDetail {
@ -19,6 +23,10 @@ interface InterviewDetail {
stage: string;
interview_date: string;
feedback: string | null;
created_at?: string;
jobs?: {
title: string;
};
}
interface RankedCandidate {
@ -35,6 +43,20 @@ interface RankedCandidate {
interview?: InterviewDetail | null;
}
interface DBCandidate {
id: string;
name: string;
contact_info: {
email: string;
phone: string;
skills?: string[];
summary?: string;
};
created_at: string;
scores?: CandidateScore[];
interviews?: InterviewDetail[];
}
export async function GET(request: NextRequest) {
try {
const supabase = createServerSupabaseClient();
@ -144,20 +166,33 @@ export async function GET(request: NextRequest) {
return NextResponse.json(candidatesList);
} else {
// Fetch all candidates sorted by created_at descending, along with scores ordered descending
// Fetch all candidates sorted by created_at descending, along with scores and interviews, including job titles
const { data: candidates, error } = await supabase
.from("candidates")
.select("*, scores(*)")
.order("created_at", { ascending: false })
.order("created_at", { referencedTable: "scores", ascending: false });
.select(`
*,
scores (
*,
jobs (
title
)
),
interviews (
*,
jobs (
title
)
)
`)
.order("created_at", { ascending: false });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
// Safeguard: Sort and normalize scores inside each candidate in Javascript as well
const typedCandidates = candidates || [];
typedCandidates.forEach(cand => {
const typedCandidates = (candidates as unknown as DBCandidate[]) || [];
typedCandidates.forEach((cand: DBCandidate) => {
if (cand.scores && Array.isArray(cand.scores)) {
cand.scores.forEach((s: CandidateScore) => {
if (s.ai_score <= 1.0) {
@ -179,6 +214,13 @@ export async function GET(request: NextRequest) {
});
cand.scores.sort((a: CandidateScore, b: CandidateScore) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
}
if (cand.interviews && Array.isArray(cand.interviews)) {
cand.interviews.sort((a: InterviewDetail, b: InterviewDetail) => {
const dateA = a.created_at ? new Date(a.created_at).getTime() : 0;
const dateB = b.created_at ? new Date(b.created_at).getTime() : 0;
return dateB - dateA;
});
}
});
return NextResponse.json(typedCandidates);
@ -188,3 +230,29 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
const supabase = createServerSupabaseClient();
const id = request.nextUrl.searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "Candidate ID is required" }, { status: 400 });
}
const { error } = await supabase
.from("candidates")
.delete()
.eq("id", id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true, message: "Candidate deleted successfully" });
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Internal Server Error";
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}

View file

@ -13,13 +13,30 @@ const config: Config = {
white: "#ffffff",
slate: {
50: "#f8fafc",
200: "#e2e8f0",
500: "#64748b",
600: "#475569",
900: "#0f172a",
},
blue: {
50: "#eff6ff",
200: "#bfdbfe",
600: "#2563eb",
700: "#1d4ed8",
},
red: {
50: "#fef2f2",
200: "#fecaca",
600: "#dc2626",
700: "#b91c1c",
},
green: {
50: "#f0fdf4",
100: "#dcfce7",
200: "#bbf7d0",
700: "#15803d",
800: "#166534",
},
},
},
plugins: [],