From debbdcd8dd4783f02736d4a881c595b7e0eb9007 Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo[bot]@users.noreply.github.com> Date: Sat, 28 Feb 2026 17:44:49 +0000 Subject: [PATCH 1/2] feat(batch): add batch upload for resume analysis against multiple jobs --- app/(dashboard)/app/batch-upload/page.tsx | 20 + app/(dashboard)/app/batch/[id]/page.tsx | 237 ++++++++++ app/api/batch-analyze/route.ts | 245 ++++++++++ app/components/AppSidebar.tsx | 3 +- app/components/BatchUploadForm.tsx | 549 ++++++++++++++++++++++ app/components/Navbar.tsx | 12 + lib/api.ts | 53 +++ lib/rate-limit.ts | 1 + lib/resume-analysis.ts | 67 +++ lib/schemas.ts | 10 + prisma/schema.prisma | 28 +- types/index.d.ts | 30 ++ 12 files changed, 1250 insertions(+), 5 deletions(-) create mode 100644 app/(dashboard)/app/batch-upload/page.tsx create mode 100644 app/(dashboard)/app/batch/[id]/page.tsx create mode 100644 app/api/batch-analyze/route.ts create mode 100644 app/components/BatchUploadForm.tsx create mode 100644 lib/resume-analysis.ts diff --git a/app/(dashboard)/app/batch-upload/page.tsx b/app/(dashboard)/app/batch-upload/page.tsx new file mode 100644 index 0000000..4693bc9 --- /dev/null +++ b/app/(dashboard)/app/batch-upload/page.tsx @@ -0,0 +1,20 @@ +import BatchUploadForm from "@/app/components/BatchUploadForm"; + +export default function BatchUploadPage() { + return ( +
+
+ Batch analyze +

+ One resume, multiple jobs — analyzed in parallel +

+

+ Upload your resume once and compare it against up to 10 different job + descriptions simultaneously. See which roles match best. +

+
+ + +
+ ); +} diff --git a/app/(dashboard)/app/batch/[id]/page.tsx b/app/(dashboard)/app/batch/[id]/page.tsx new file mode 100644 index 0000000..646dcd7 --- /dev/null +++ b/app/(dashboard)/app/batch/[id]/page.tsx @@ -0,0 +1,237 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import ResumeCard from "@/app/components/ResumeCard"; +import { getServerSession } from "@/lib/auth-server"; +import { prisma } from "@/lib/prisma"; +import { FeedbackSchema } from "@/lib/schemas"; +import { + ArrowLeft, + CheckCircle2, + XCircle, + TrendingUp, + Trophy, + Layers, +} from "lucide-react"; + +export default async function BatchResultsPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const [session, { id }] = await Promise.all([getServerSession(), params]); + if (!session) { + redirect("/auth"); + } + + const batch = await prisma.batchAnalysis.findUnique({ + where: { id }, + include: { + resumes: { + select: { + id: true, + jobTitle: true, + companyName: true, + feedback: true, + previewImage: true, + createdAt: true, + }, + orderBy: { createdAt: "asc" }, + }, + }, + }); + + if (!batch || batch.userId !== session.user.id) { + redirect("/app"); + } + + const resumesWithFeedback = batch.resumes.flatMap((resume) => { + const parsed = FeedbackSchema.safeParse(resume.feedback); + if (!parsed.success) return []; + return [{ ...resume, parsedFeedback: parsed.data }]; + }); + + const scores = resumesWithFeedback.map( + (r) => r.parsedFeedback.overallScore, + ); + const avgScore = + scores.length > 0 + ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) + : null; + const bestScore = scores.length > 0 ? Math.max(...scores) : null; + const bestResume = resumesWithFeedback.find( + (r) => r.parsedFeedback.overallScore === bestScore, + ); + + const statusLabel = + batch.status === "completed" + ? "All jobs analyzed" + : batch.status === "partial" + ? `${batch.completedJobs} of ${batch.totalJobs} succeeded` + : batch.status === "failed" + ? "All jobs failed" + : "Processing"; + + return ( +
+
+ + + Back to dashboard + +
+ Batch analysis +

+ Batch results +

+

+ {statusLabel} · Analyzed on{" "} + {new Date(batch.createdAt).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + })} +

+
+
+ +
+
+
+ +
+ Total jobs +
+
+
+ + {batch.totalJobs} + +
+
+
+
+ +
+ Completed +
+
+
+ + {batch.completedJobs} + +
+
+ {batch.failedJobs > 0 && ( +
+
+ +
+ Failed +
+
+
+ + {batch.failedJobs} + +
+
+ )} +
+
+ +
+ Average score +
+
+
+ + {avgScore ?? "--"} + + {avgScore !== null && ( + /100 + )} +
+
+ {bestResume && bestScore !== null && ( +
+
+ +
+ Best match +
+
+
+ + {bestScore} + + /100 +
+

+ {bestResume.companyName || bestResume.jobTitle} +

+
+ )} +
+ +
+
+

+ Individual results +

+

+ Click any card to view the full analysis with coaching, ATS + readiness, and line-by-line improvements. +

+
+ + {resumesWithFeedback.length === 0 ? ( +
+

+ No successful analyses +

+

+ All jobs in this batch failed to analyze. Try again with different + job descriptions. +

+ + Start a new batch + +
+ ) : ( +
+ {resumesWithFeedback.map((resume) => ( + + ))} +
+ )} +
+ +
+ + Start another batch analysis + +
+
+ ); +} diff --git a/app/api/batch-analyze/route.ts b/app/api/batch-analyze/route.ts new file mode 100644 index 0000000..c877524 --- /dev/null +++ b/app/api/batch-analyze/route.ts @@ -0,0 +1,245 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withAuthAndRateLimit } from "@/lib/api-middleware"; +import { FeedbackSchema, BatchAnalyzeInputSchema } from "@/lib/schemas"; +import { + convertPdfToMarkdown, + analyzeResumeWithAI, + MAX_MARKDOWN_LENGTH, +} from "@/lib/resume-analysis"; +import { prisma } from "@/lib/prisma"; +import type { BatchJobEntry } from "@/types"; + +export const maxDuration = 300; + +const CONCURRENCY = 3; + +class Semaphore { + private permits: number; + private waiters: (() => void)[] = []; + + constructor(permits: number) { + this.permits = permits; + } + + async acquire(): Promise { + if (this.permits > 0) { + this.permits--; + return; + } + return new Promise((resolve) => { + this.waiters.push(resolve); + }); + } + + release(): void { + const waiter = this.waiters.shift(); + if (waiter) { + waiter(); + } else { + this.permits++; + } + } +} + +export async function POST(request: NextRequest) { + return await withAuthAndRateLimit( + request, + "/api/batch-analyze", + async ({ userId }) => { + const formData = await request.formData(); + const file = formData.get("file") as File | null; + const jobsRaw = formData.get("jobs") as string | null; + + if (!file || !jobsRaw) { + return NextResponse.json( + { success: false, error: "Missing required fields" }, + { status: 400 }, + ); + } + + if (file.type !== "application/pdf") { + return NextResponse.json( + { success: false, error: "Only PDF files are supported" }, + { status: 400 }, + ); + } + + if (file.size > 20 * 1024 * 1024) { + return NextResponse.json( + { success: false, error: "File size must be under 20 MB" }, + { status: 400 }, + ); + } + + let jobs: BatchJobEntry[]; + try { + const parsed = JSON.parse(jobsRaw); + const validation = BatchAnalyzeInputSchema.safeParse({ jobs: parsed }); + if (!validation.success) { + return NextResponse.json( + { + success: false, + error: validation.error.issues[0]?.message || "Invalid job data", + }, + { status: 400 }, + ); + } + jobs = validation.data.jobs; + } catch { + return NextResponse.json( + { success: false, error: "Invalid jobs format" }, + { status: 400 }, + ); + } + + let markdown: string; + let previewImage: string | null; + try { + const buffer = Buffer.from(await file.arrayBuffer()); + const result = await convertPdfToMarkdown(buffer); + markdown = result.markdown; + previewImage = result.previewImage; + } catch { + return NextResponse.json( + { + success: false, + error: "PDF service unavailable. Please try again later.", + }, + { status: 502 }, + ); + } + + if (markdown.length >= MAX_MARKDOWN_LENGTH) { + return NextResponse.json( + { + success: false, + error: "Resume too detailed. Please use a simpler format.", + }, + { status: 400 }, + ); + } + + const batch = await prisma.batchAnalysis.create({ + data: { + userId, + totalJobs: jobs.length, + status: "processing", + }, + }); + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + function send(data: Record) { + controller.enqueue( + encoder.encode(JSON.stringify(data) + "\n"), + ); + } + + send({ + type: "init", + batchId: batch.id, + totalJobs: jobs.length, + }); + + let completed = 0; + let failed = 0; + const sem = new Semaphore(CONCURRENCY); + + const promises = jobs.map(async (job, index) => { + await sem.acquire(); + try { + const feedback = await analyzeResumeWithAI( + markdown, + job.jobTitle, + job.jobDescription, + "low", + job.companyName, + ); + + const validation = FeedbackSchema.safeParse(feedback); + if (!validation.success) { + throw new Error("AI returned malformed response"); + } + + const resume = await prisma.resume.create({ + data: { + userId, + batchId: batch.id, + jobTitle: job.jobTitle, + jobDescription: job.jobDescription, + companyName: job.companyName || null, + resumeMarkdown: markdown, + feedback: validation.data, + previewImage, + }, + }); + + completed++; + send({ + type: "progress", + jobIndex: index, + status: "success", + resumeId: resume.id, + jobTitle: job.jobTitle, + companyName: job.companyName, + overallScore: validation.data.overallScore, + }); + } catch (error) { + failed++; + send({ + type: "progress", + jobIndex: index, + status: "error", + jobTitle: job.jobTitle, + companyName: job.companyName, + error: + error instanceof Error + ? error.message + : "Analysis failed", + }); + } finally { + sem.release(); + } + }); + + await Promise.all(promises); + + const finalStatus = + failed === jobs.length + ? "failed" + : failed > 0 + ? "partial" + : "completed"; + + await prisma.batchAnalysis.update({ + where: { id: batch.id }, + data: { + completedJobs: completed, + failedJobs: failed, + status: finalStatus, + }, + }); + + send({ + type: "complete", + batchId: batch.id, + completed, + failed, + status: finalStatus, + }); + + controller.close(); + }, + }); + + return new NextResponse(stream, { + headers: { + "Content-Type": "application/x-ndjson", + "Transfer-Encoding": "chunked", + "Cache-Control": "no-cache", + }, + }); + }, + ); +} diff --git a/app/components/AppSidebar.tsx b/app/components/AppSidebar.tsx index e7c1fa1..0521766 100644 --- a/app/components/AppSidebar.tsx +++ b/app/components/AppSidebar.tsx @@ -5,7 +5,7 @@ import { usePathname, useRouter } from "next/navigation"; import { signOut } from "@/lib/auth-client"; import { toast } from "sonner"; import { cn } from "@/app/lib/utils"; -import { LogOut, FileText, Upload, Mail, Send } from "lucide-react"; +import { LogOut, FileText, Upload, Mail, Send, Layers } from "lucide-react"; interface AppSidebarProps { user: { @@ -29,6 +29,7 @@ const navItems = [ { href: "/app/cover-letter", label: "Cover Letters", icon: Mail }, { href: "/app/outreach", label: "Outreach", icon: Send }, { href: "/app/upload", label: "Analyze Resume", icon: Upload }, + { href: "/app/batch-upload", label: "Batch Analyze", icon: Layers }, ]; export default function AppSidebar({ user }: AppSidebarProps) { diff --git a/app/components/BatchUploadForm.tsx b/app/components/BatchUploadForm.tsx new file mode 100644 index 0000000..5bf0140 --- /dev/null +++ b/app/components/BatchUploadForm.tsx @@ -0,0 +1,549 @@ +"use client"; + +import { useState, useCallback } from "react"; +import dynamic from "next/dynamic"; +import { useRouter } from "next/navigation"; +import FileUploader from "@/app/components/FileUploader"; + +const ImportJobModal = dynamic( + () => import("@/app/components/ImportJobModal"), + { ssr: false }, +); + +import { cn } from "@/app/lib/utils"; +import { toast } from "sonner"; +import { + Globe, + Plus, + X, + Check, + AlertCircle, + Loader2, + ArrowRight, + Layers, +} from "lucide-react"; +import { batchAnalyze, importJobFromUrl, importJobFromPdf } from "@/lib/api"; +import type { BatchJobEntry, BatchStreamEvent } from "@/types"; + +interface JobEntryState extends BatchJobEntry { + id: string; +} + +interface JobResult { + status: "pending" | "analyzing" | "success" | "error"; + resumeId?: string; + overallScore?: number; + error?: string; +} + +function createEmptyJob(): JobEntryState { + return { + id: crypto.randomUUID(), + jobTitle: "", + jobDescription: "", + companyName: "", + }; +} + +export default function BatchUploadForm() { + const router = useRouter(); + + const [file, setFile] = useState(null); + const [fileError, setFileError] = useState(""); + const [jobs, setJobs] = useState([ + createEmptyJob(), + createEmptyJob(), + ]); + + const [phase, setPhase] = useState<"form" | "processing" | "done">("form"); + const [results, setResults] = useState>(new Map()); + const [batchId, setBatchId] = useState(null); + const [importModalOpen, setImportModalOpen] = useState(false); + const [importTargetIndex, setImportTargetIndex] = useState(0); + const [isImporting, setIsImporting] = useState(false); + + const isProcessing = phase !== "form"; + + const addJob = useCallback(() => { + if (jobs.length >= 10) { + toast.error("Maximum 10 jobs per batch"); + return; + } + setJobs((prev) => [...prev, createEmptyJob()]); + }, [jobs.length]); + + const removeJob = useCallback( + (index: number) => { + if (jobs.length <= 2) { + toast.error("At least 2 jobs required"); + return; + } + setJobs((prev) => prev.filter((_, i) => i !== index)); + }, + [jobs.length], + ); + + const updateJob = useCallback( + (index: number, field: keyof BatchJobEntry, value: string) => { + setJobs((prev) => + prev.map((job, i) => (i === index ? { ...job, [field]: value } : job)), + ); + }, + [], + ); + + const handleImportFromUrl = async (url: string) => { + setIsImporting(true); + try { + const extracted = await importJobFromUrl(url); + updateJob(importTargetIndex, "companyName", extracted.companyName); + updateJob(importTargetIndex, "jobTitle", extracted.jobTitle); + updateJob(importTargetIndex, "jobDescription", extracted.jobDescription); + toast.success("Job details imported"); + setImportModalOpen(false); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Import failed"; + toast.error("Import failed", { description: errorMessage }); + } finally { + setIsImporting(false); + } + }; + + const handleImportFromPdf = async (importedFile: File) => { + setIsImporting(true); + try { + const extracted = await importJobFromPdf(importedFile); + updateJob(importTargetIndex, "companyName", extracted.companyName); + updateJob(importTargetIndex, "jobTitle", extracted.jobTitle); + updateJob(importTargetIndex, "jobDescription", extracted.jobDescription); + toast.success("Job details imported"); + setImportModalOpen(false); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Import failed"; + toast.error("Import failed", { description: errorMessage }); + } finally { + setIsImporting(false); + } + }; + + const validateJobs = (): string | null => { + for (let i = 0; i < jobs.length; i++) { + const job = jobs[i]; + if (!job.jobTitle.trim()) return `Job ${i + 1}: Title is required`; + if (!job.jobDescription.trim()) + return `Job ${i + 1}: Description is required`; + if (job.jobDescription.trim().length < 50) + return `Job ${i + 1}: Description needs at least 50 characters`; + } + return null; + }; + + const handleSubmit = async () => { + if (!file) { + toast.error("Resume required", { + description: "Upload your resume PDF to continue.", + }); + return; + } + + const validationError = validateJobs(); + if (validationError) { + toast.error("Fix job entries", { description: validationError }); + return; + } + + setPhase("processing"); + const initialResults = new Map(); + jobs.forEach((_, i) => { + initialResults.set(i, { status: "analyzing" }); + }); + setResults(initialResults); + + try { + await batchAnalyze( + file, + jobs.map(({ jobTitle, jobDescription, companyName }) => ({ + jobTitle: jobTitle.trim(), + jobDescription: jobDescription.trim(), + companyName: companyName?.trim() || undefined, + })), + (event: BatchStreamEvent) => { + if (event.type === "init" && event.batchId) { + setBatchId(event.batchId); + } + + if (event.type === "progress" && event.jobIndex !== undefined) { + setResults((prev) => { + const next = new Map(prev); + next.set(event.jobIndex!, { + status: event.status === "success" ? "success" : "error", + resumeId: event.resumeId, + overallScore: event.overallScore, + error: event.error, + }); + return next; + }); + } + + if (event.type === "complete") { + setPhase("done"); + } + }, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Batch analysis failed"; + + if ( + errorMessage.includes("Too many requests") || + errorMessage.includes("rate limit") + ) { + toast.error("Rate limit exceeded", { + description: "Please wait before starting another batch.", + }); + } else { + toast.error("Batch analysis failed", { description: errorMessage }); + } + + setPhase("form"); + setResults(new Map()); + } + }; + + const completedCount = [...results.values()].filter( + (r) => r.status === "success", + ).length; + const failedCount = [...results.values()].filter( + (r) => r.status === "error", + ).length; + const totalCount = jobs.length; + + if (phase === "processing" || phase === "done") { + return ( +
+
+
+
+
+ {phase === "done" ? ( + + ) : ( + + )} +
+
+

+ {phase === "done" + ? "Batch analysis complete" + : "Analyzing your resume..."} +

+

+ {completedCount + failedCount} of {totalCount} jobs processed +

+
+
+ {phase === "done" && batchId && ( + + )} +
+ +
+
0 + ? "bg-gradient-to-r from-indigo-500 to-amber-400" + : "bg-gradient-to-r from-indigo-500 to-emerald-500" + : "bg-gradient-to-r from-indigo-500 to-indigo-400", + )} + style={{ + width: `${((completedCount + failedCount) / totalCount) * 100}%`, + }} + /> +
+
+ +
+ {jobs.map((job, index) => { + const result = results.get(index); + const status = result?.status || "pending"; + + return ( +
+
+
+

+ Job {index + 1} +

+

+ {job.jobTitle || "Untitled"} +

+ {job.companyName && ( +

+ {job.companyName} +

+ )} +
+ +
+ {status === "analyzing" && ( +
+ +
+ )} + {status === "success" && result?.overallScore !== undefined && ( +
= 80 + ? "bg-emerald-100 text-emerald-700" + : result.overallScore >= 60 + ? "bg-amber-100 text-amber-700" + : "bg-red-100 text-red-700", + )} + > + {result.overallScore} +
+ )} + {status === "error" && ( +
+ +
+ )} +
+
+ + {status === "success" && result?.resumeId && ( + + )} + + {status === "error" && result?.error && ( +

{result.error}

+ )} +
+ ); + })} +
+ + {phase === "done" && ( +
+ +
+ )} +
+ ); + } + + return ( + <> +
+
+
+
+ 1 +
+

+ Upload your resume +

+
+

+ One resume, analyzed against every job below. +

+
+ +
+
+ +
+
+
+
+ 2 +
+

+ Add target jobs +

+ + {jobs.length}/10 + +
+
+

+ Add 2-10 job descriptions to compare your resume against. +

+ +
+ {jobs.map((job, index) => ( +
+
+ + {index + 1} + +
+
+
+ + + updateJob(index, "jobTitle", e.target.value) + } + placeholder="e.g. Senior Product Designer" + className="input-field" + disabled={isProcessing} + /> +
+
+ + + updateJob(index, "companyName", e.target.value) + } + placeholder="e.g. Aurora Labs" + className="input-field" + disabled={isProcessing} + /> +
+
+
+ +