From 00277d1da795d7031ba9ffd4cf1a03b6a56879b1 Mon Sep 17 00:00:00 2001
From: Aditya Mer
Date: Sun, 22 Feb 2026 13:37:06 +0530
Subject: [PATCH 1/7] feat: add Gemini-powered LaTeX resume builder
---
.env.example | 1 +
README.md | 29 +++
convex/functions.ts | 77 ++++++
convex/schema.ts | 15 ++
src/app/api/generate-resume-latex/route.ts | 148 +++++++++++
src/app/dashboard/page.tsx | 8 +
src/app/dashboard/resume-builder/page.tsx | 243 ++++++++++++++++++
src/components/layout/index.tsx | 8 +-
src/lib/contracts/api.ts | 21 ++
src/lib/convex-server.ts | 82 ++++++
src/lib/gemini.ts | 143 ++++++++++-
src/lib/resume-latex.ts | 216 ++++++++++++++++
.../api/generate-resume-latex/route.test.ts | 105 ++++++++
test/lib/gemini.test.ts | 23 +-
test/lib/resume-latex.test.ts | 46 ++++
15 files changed, 1150 insertions(+), 15 deletions(-)
create mode 100644 src/app/api/generate-resume-latex/route.ts
create mode 100644 src/app/dashboard/resume-builder/page.tsx
create mode 100644 src/lib/resume-latex.ts
create mode 100644 test/app/api/generate-resume-latex/route.test.ts
create mode 100644 test/lib/resume-latex.test.ts
diff --git a/.env.example b/.env.example
index ab9ae7a..e4f6659 100644
--- a/.env.example
+++ b/.env.example
@@ -5,6 +5,7 @@ MODEL_NAME="gemini-2.5-flash"
AI_TIMEOUT_MS="30000"
PDF_PARSE_TIMEOUT_MS="12000"
COVER_LETTER_ROUTE_TIMEOUT_MS="35000"
+RESUME_ROUTE_TIMEOUT_MS="45000"
# Convex Variables
NEXT_PUBLIC_CONVEX_URL=""
diff --git a/README.md b/README.md
index bfff794..6a8035e 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,7 @@ AI-powered resume analysis and cover-letter generation built with Next.js, Stack
- Resume analysis with match scoring, strengths, weaknesses, skills match, and recommendations
- Cover-letter generation with configurable tone and length
+- Tailored LaTeX resume generation using selectable templates
- PDF resume upload and parsing
- Searchable history for analyses and cover letters
- Auth-protected dashboard flows
@@ -68,6 +69,7 @@ MODEL_NAME="gemini-2.5-flash"
AI_TIMEOUT_MS="30000"
PDF_PARSE_TIMEOUT_MS="12000"
COVER_LETTER_ROUTE_TIMEOUT_MS="35000"
+RESUME_ROUTE_TIMEOUT_MS="45000"
UPSTASH_REDIS_REST_URL=""
UPSTASH_REDIS_REST_TOKEN=""
# Only set true for local emergency fallback; keep false/empty in production
@@ -103,6 +105,7 @@ Primary user flows are under dashboard routes:
- `/dashboard/analysis/[slug]`
- `/dashboard/cover-letter`
- `/dashboard/cover-letter/[slug]`
+- `/dashboard/resume-builder`
- `/dashboard/history`
- `/dashboard/upload`
@@ -169,6 +172,31 @@ Error response shape:
- `{ code, message, details?, requestId }` with status `400 | 401 | 429 | 500 | 503 | 504`
+### `POST /api/generate-resume-latex`
+
+Generates a job-tailored LaTeX resume using a selected template.
+
+Body fields:
+
+- `resumeText: string` (required)
+- `jobDescription: string` (required)
+- `templateId: "awesome-classic" | "deedy-modern" | "sb2nov-ats"` (optional, default `awesome-classic`)
+- `resumeName?: string`
+- `forceRegenerate?: boolean`
+
+Success response:
+
+- `latexSource: string`
+- `structuredData: object`
+- `templateId: string`
+- `cached: boolean`
+- `source?: "database"`
+- `documentId?: string`
+
+Error response shape:
+
+- `{ code, message, details?, requestId }` with status `400 | 401 | 429 | 500 | 503 | 504`
+
### Other API routes
- `POST /api/parse-pdf`
@@ -206,6 +234,7 @@ src/app/
analysis/
cover-letter/
history/
+ resume-builder/
upload/
handler/[...stack]/
page.tsx
diff --git a/convex/functions.ts b/convex/functions.ts
index 7607d5d..12e84ce 100644
--- a/convex/functions.ts
+++ b/convex/functions.ts
@@ -245,6 +245,83 @@ export const getUserCoverLetters = query({
},
});
+// ─── Tailored Resume Functions ───────────────────────────────────────
+
+export const saveTailoredResume = mutation({
+ args: {
+ userId: v.string(),
+ resumeHash: v.string(),
+ jobDescriptionHash: v.string(),
+ templateId: v.string(),
+ jobTitle: v.optional(v.string()),
+ companyName: v.optional(v.string()),
+ resumeName: v.optional(v.string()),
+ jobDescription: v.optional(v.string()),
+ structuredData: v.string(),
+ latexSource: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const id = await ctx.db.insert("tailoredResumes", args);
+ const doc = await ctx.db.get(id);
+ return doc;
+ },
+});
+
+export const getTailoredResume = query({
+ args: {
+ userId: v.string(),
+ resumeHash: v.string(),
+ jobDescriptionHash: v.string(),
+ templateId: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const doc = await ctx.db
+ .query("tailoredResumes")
+ .filter((q) =>
+ q.and(
+ q.eq(q.field("userId"), args.userId),
+ q.eq(q.field("resumeHash"), args.resumeHash),
+ q.eq(q.field("jobDescriptionHash"), args.jobDescriptionHash),
+ q.eq(q.field("templateId"), args.templateId),
+ )
+ )
+ .order("desc")
+ .first();
+ return doc;
+ },
+});
+
+export const getTailoredResumeById = query({
+ args: { tailoredResumeId: v.id("tailoredResumes") },
+ handler: async (ctx, args) => {
+ return await ctx.db.get(args.tailoredResumeId);
+ },
+});
+
+export const deleteTailoredResume = mutation({
+ args: { tailoredResumeId: v.id("tailoredResumes") },
+ handler: async (ctx, args) => {
+ await ctx.db.delete(args.tailoredResumeId);
+ return { success: true };
+ },
+});
+
+export const getUserTailoredResumes = query({
+ args: {
+ userId: v.string(),
+ limit: v.optional(v.number()),
+ },
+ handler: async (ctx, args) => {
+ const limit = args.limit ?? 20;
+ const docs = await ctx.db
+ .query("tailoredResumes")
+ .withIndex("by_userId", (q) => q.eq("userId", args.userId))
+ .order("desc")
+ .take(limit);
+ return docs;
+ },
+});
+
// ─── Stats ───────────────────────────────────────────────────────────
export const getUserStats = query({
diff --git a/convex/schema.ts b/convex/schema.ts
index e2ee07d..d4a1295 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -40,4 +40,19 @@ export default defineSchema({
})
.index("by_userId", ["userId"])
.index("by_lookup", ["userId", "resumeHash", "jobDescriptionHash", "tone", "length"]),
+
+ tailoredResumes: defineTable({
+ userId: v.string(),
+ resumeHash: v.string(),
+ jobDescriptionHash: v.string(),
+ templateId: v.string(),
+ jobTitle: v.optional(v.string()),
+ companyName: v.optional(v.string()),
+ resumeName: v.optional(v.string()),
+ jobDescription: v.optional(v.string()),
+ structuredData: v.string(),
+ latexSource: v.string(),
+ })
+ .index("by_userId", ["userId"])
+ .index("by_lookup", ["userId", "resumeHash", "jobDescriptionHash", "templateId"]),
});
diff --git a/src/app/api/generate-resume-latex/route.ts b/src/app/api/generate-resume-latex/route.ts
new file mode 100644
index 0000000..7e56537
--- /dev/null
+++ b/src/app/api/generate-resume-latex/route.ts
@@ -0,0 +1,148 @@
+import { randomUUID } from 'crypto';
+import { NextRequest } from 'next/server';
+
+import { apiError, apiSuccess } from '@/lib/api-response';
+import { withTimeout } from '@/lib/async-timeout';
+import { checkRateLimit, getAuthenticatedUser } from '@/lib/auth';
+import { tailoredResumeRequestSchema } from '@/lib/contracts/api';
+import { generateHash, getTailoredResume, saveTailoredResume } from '@/lib/convex-server';
+import { generateTailoredResumeData } from '@/lib/gemini';
+import { getIdempotentResponse, setIdempotentResponse } from '@/lib/idempotency';
+import { buildLatexResume } from '@/lib/resume-latex';
+
+const RESUME_ROUTE_TIMEOUT_MS = Number(process.env.RESUME_ROUTE_TIMEOUT_MS || 45000);
+
+export async function POST(request: NextRequest) {
+ const requestId = request.headers.get('x-request-id') ?? randomUUID();
+
+ try {
+ const userId = await getAuthenticatedUser();
+ if (!userId) {
+ return apiError(requestId, 401, 'AUTH_REQUIRED', 'Authentication required');
+ }
+
+ const rateLimit = await checkRateLimit(`resume-latex-${userId}`, { windowMs: 60000, maxRequests: 12 });
+ if (!rateLimit.allowed) {
+ return apiError(
+ requestId,
+ 429,
+ 'RATE_LIMITED',
+ `Rate limit exceeded. Try again in ${Math.ceil(rateLimit.resetIn / 1000)} seconds.`,
+ );
+ }
+
+ const parse = tailoredResumeRequestSchema.safeParse(await request.json());
+ if (!parse.success) {
+ return apiError(requestId, 400, 'VALIDATION_ERROR', 'Invalid request payload', parse.error.flatten());
+ }
+
+ const payload = parse.data;
+ const {
+ resumeText,
+ jobDescription,
+ templateId,
+ resumeName,
+ forceRegenerate,
+ idempotencyKey,
+ } = payload;
+
+ const idemHeader = request.headers.get('idempotency-key')?.trim();
+ const effectiveIdempotencyKey = idempotencyKey ?? idemHeader;
+
+ if (effectiveIdempotencyKey) {
+ const replay = getIdempotentResponse>(`${userId}:tailoredResume:${effectiveIdempotencyKey}`);
+ if (replay) {
+ return apiSuccess(replay.payload);
+ }
+ }
+
+ const resumeHash = generateHash(resumeText);
+ const jobDescriptionHash = generateHash(jobDescription);
+
+ if (!forceRegenerate) {
+ const cached = await getTailoredResume(userId, resumeHash, jobDescriptionHash, templateId);
+ if (cached) {
+ let structuredData: Record = {};
+ try {
+ structuredData = JSON.parse(cached.structuredData) as Record;
+ } catch {
+ structuredData = {};
+ }
+
+ const response = {
+ latexSource: cached.latexSource,
+ structuredData,
+ templateId: cached.templateId,
+ cached: true,
+ source: 'database' as const,
+ documentId: cached._id,
+ requestId,
+ };
+
+ if (effectiveIdempotencyKey) {
+ setIdempotentResponse(`${userId}:tailoredResume:${effectiveIdempotencyKey}`, { status: 200, payload: response });
+ }
+
+ return apiSuccess(response);
+ }
+ }
+
+ const structuredData = await withTimeout(
+ generateTailoredResumeData(resumeText, jobDescription),
+ RESUME_ROUTE_TIMEOUT_MS,
+ 'Resume generation timed out. Please try again.',
+ );
+
+ const latexSource = buildLatexResume(templateId, structuredData);
+
+ let documentId: string | undefined;
+ try {
+ const saved = await saveTailoredResume({
+ userId,
+ resumeHash,
+ jobDescriptionHash,
+ templateId,
+ jobTitle: structuredData.targetTitle,
+ resumeName,
+ jobDescription,
+ structuredData: JSON.stringify(structuredData),
+ latexSource,
+ });
+ documentId = saved._id;
+ } catch (saveError) {
+ console.error('Error saving tailored resume', { requestId, error: saveError });
+ }
+
+ const response = {
+ latexSource,
+ structuredData,
+ templateId,
+ cached: false,
+ documentId,
+ requestId,
+ };
+
+ if (effectiveIdempotencyKey) {
+ setIdempotentResponse(`${userId}:tailoredResume:${effectiveIdempotencyKey}`, { status: 200, payload: response });
+ }
+
+ return apiSuccess(response);
+ } catch (error) {
+ console.error('Tailored resume generation error', { requestId, error });
+
+ if (error instanceof Error) {
+ if (error.message.includes('timed out')) {
+ return apiError(requestId, 504, 'RESUME_TIMEOUT', error.message);
+ }
+ if (error.message.includes('quota') || error.message.includes('rate')) {
+ return apiError(requestId, 429, 'UPSTREAM_RATE_LIMITED', 'AI rate limit exceeded. Please try again shortly.');
+ }
+ if (error.message.includes('API key')) {
+ return apiError(requestId, 500, 'AI_CONFIG_ERROR', 'AI service configuration error. Please contact support.');
+ }
+ return apiError(requestId, 500, 'RESUME_GENERATION_FAILED', error.message);
+ }
+
+ return apiError(requestId, 500, 'RESUME_GENERATION_FAILED', 'Failed to generate tailored resume');
+ }
+}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 33550ca..fac2a69 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -6,6 +6,7 @@ import {
BarChart3,
Clock,
Compass,
+ FileCode2,
FileText,
Loader2,
Sparkles,
@@ -45,6 +46,13 @@ const actions = [
icon: Sparkles,
cta: 'Create letter',
},
+ {
+ title: 'Build LaTeX Resume',
+ description: 'Generate a job-tailored resume in popular LaTeX templates.',
+ href: '/dashboard/resume-builder',
+ icon: FileCode2,
+ cta: 'Open builder',
+ },
{
title: 'View History',
description: 'Review previous analyses and letters so you can iterate fast.',
diff --git a/src/app/dashboard/resume-builder/page.tsx b/src/app/dashboard/resume-builder/page.tsx
new file mode 100644
index 0000000..b84f8b3
--- /dev/null
+++ b/src/app/dashboard/resume-builder/page.tsx
@@ -0,0 +1,243 @@
+'use client'
+
+import { AlertCircle, CheckCircle, Copy, Download, FileCode2, FileText, Sparkles } from 'lucide-react'
+import { useState } from 'react'
+
+import { ResumeSelect } from '@/components/resume/ResumeSelect'
+import { Button } from '@/components/ui/button'
+import { Textarea } from '@/components/ui/textarea'
+import { RESUME_TEMPLATE_OPTIONS, type ResumeTemplateId } from '@/lib/resume-latex'
+
+interface TailoredResumeResponse {
+ latexSource: string
+ structuredData: Record
+ templateId: ResumeTemplateId
+ cached: boolean
+ source?: 'database'
+ documentId?: string
+ requestId: string
+}
+
+function downloadLatex(content: string, fileName: string) {
+ const blob = new Blob([content], { type: 'application/x-tex;charset=utf-8' })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = fileName
+ document.body.appendChild(link)
+ link.click()
+ link.remove()
+ URL.revokeObjectURL(url)
+}
+
+function safeFileName(input: string) {
+ const normalized = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-')
+ return normalized.length > 0 ? normalized.slice(0, 80) : 'tailored-resume'
+}
+
+export default function ResumeBuilderPage() {
+ const [jobDescription, setJobDescription] = useState('')
+ const [resumeText, setResumeText] = useState(null)
+ const [resumeName, setResumeName] = useState(null)
+ const [templateId, setTemplateId] = useState('awesome-classic')
+ const [isGenerating, setIsGenerating] = useState(false)
+ const [error, setError] = useState(null)
+ const [result, setResult] = useState(null)
+ const [copied, setCopied] = useState(false)
+
+ const handleGenerate = async () => {
+ if (!jobDescription.trim()) {
+ setError('Please enter a job description')
+ return
+ }
+ if (!resumeText) {
+ setError('Please select a resume first')
+ return
+ }
+
+ setIsGenerating(true)
+ setError(null)
+
+ try {
+ const response = await fetch('/api/generate-resume-latex', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ resumeText,
+ jobDescription: jobDescription.trim(),
+ templateId,
+ resumeName: resumeName || undefined,
+ idempotencyKey: crypto.randomUUID(),
+ }),
+ })
+
+ const data = await response.json()
+ if (!response.ok) {
+ throw new Error(data.message || data.error || 'Failed to generate LaTeX resume')
+ }
+
+ setResult(data as TailoredResumeResponse)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to generate resume')
+ setResult(null)
+ } finally {
+ setIsGenerating(false)
+ }
+ }
+
+ const handleCopy = async () => {
+ if (!result?.latexSource) return
+ await navigator.clipboard.writeText(result.latexSource)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1500)
+ }
+
+ const handleDownload = () => {
+ if (!result?.latexSource) return
+ const fileBase = safeFileName(resumeName || 'tailored-resume')
+ downloadLatex(result.latexSource, `${fileBase}-${templateId}.tex`)
+ }
+
+ return (
+
+
+
+
+
+
+
+ Resume Builder
+
+ Generate tailored LaTeX resume
+
+ Pick a resume template, paste the job description, and generate role-tailored LaTeX source powered by Gemini.
+
+
+
+
+
+
+
+
+
+
+
Job description
+
Paste complete role requirements for best tailoring.
+
+
+
+
+
+
+
+
+ {
+ setResumeText(text)
+ setResumeName(name)
+ }} selectedName={resumeName ?? undefined} />
+
+
+
+ Choose template
+
+ {RESUME_TEMPLATE_OPTIONS.map((template) => (
+
setTemplateId(template.id)}
+ className={`w-full rounded-xl border p-3 text-left transition-colors ${templateId === template.id ? 'border-primary bg-primary/10' : 'border-border/70 bg-background/70 hover:border-border'}`}
+ >
+ {template.name}
+ {template.description}
+
+ ))}
+
+
+
+
+ Notes
+
+ Gemini is instructed not to invent facts that are missing from your original resume.
+ Output is `.tex` source now, so you can compile with your preferred LaTeX toolchain.
+ Try multiple templates and compare ATS readability and visual style.
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/layout/index.tsx b/src/components/layout/index.tsx
index 4f3c79d..da804fa 100644
--- a/src/components/layout/index.tsx
+++ b/src/components/layout/index.tsx
@@ -4,12 +4,13 @@ import { UserButton,useUser } from '@stackframe/stack'
import {
BarChart3,
Clock,
+ FileCode2,
FileEdit,
LayoutDashboard,
Menu,
- Sparkles,
Upload,
X} from 'lucide-react'
+import Image from 'next/image'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useState } from 'react'
@@ -21,6 +22,7 @@ const navigation = [
{ name: 'Upload', href: '/dashboard/upload', icon: Upload },
{ name: 'Analysis', href: '/dashboard/analysis', icon: BarChart3 },
{ name: 'Cover Letter', href: '/dashboard/cover-letter', icon: FileEdit },
+ { name: 'Resume Builder', href: '/dashboard/resume-builder', icon: FileCode2 },
{ name: 'History', href: '/dashboard/history', icon: Clock },
]
@@ -77,9 +79,7 @@ export function TopNav() {
-
-
-
+
ATS
diff --git a/src/lib/contracts/api.ts b/src/lib/contracts/api.ts
index c0f8a0c..758fc6e 100644
--- a/src/lib/contracts/api.ts
+++ b/src/lib/contracts/api.ts
@@ -3,6 +3,7 @@ import { z } from 'zod';
export const toneSchema = z.enum(['professional', 'friendly', 'enthusiastic']);
export const lengthSchema = z.enum(['concise', 'standard', 'detailed']);
export const analysisTypeSchema = z.enum(['overview', 'keywords', 'match', 'coverLetter']);
+export const resumeTemplateIdSchema = z.enum(['awesome-classic', 'deedy-modern', 'sb2nov-ats']);
const freeTextSchema = z
.string()
@@ -52,6 +53,15 @@ export const coverLetterRequestSchema = analyzeRequestSchema
length: lengthSchema.default('standard'),
});
+export const tailoredResumeRequestSchema = z.object({
+ resumeText: z.string().trim().min(1, 'Resume text is required').max(50000, 'Resume text is too long (max 50,000 characters)'),
+ jobDescription: z.string().trim().min(1, 'Job description is required').max(15000, 'Job description is too long (max 15,000 characters)'),
+ templateId: resumeTemplateIdSchema.default('awesome-classic'),
+ resumeName: optionalFreeTextSchema,
+ forceRegenerate: z.boolean().optional(),
+ idempotencyKey: z.string().trim().min(8).max(128).optional(),
+});
+
export const paginationSchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(50),
cursor: z.string().trim().min(1).optional(),
@@ -101,6 +111,16 @@ export const coverLetterResponseSchema = z.object({
requestId: z.string(),
});
+export const tailoredResumeResponseSchema = z.object({
+ latexSource: z.string(),
+ structuredData: z.record(z.string(), z.unknown()),
+ templateId: resumeTemplateIdSchema,
+ cached: z.boolean(),
+ source: z.enum(['database']).optional(),
+ documentId: z.string().optional(),
+ requestId: z.string(),
+});
+
export const resumesResponseSchema = z.object({
resumes: z.array(
z.object({
@@ -137,5 +157,6 @@ export const draftResponseSchema = z.object({
export type AnalyzeRequest = z.infer
;
export type CoverLetterRequest = z.infer;
+export type TailoredResumeRequest = z.infer;
export type PaginationInput = z.infer;
export type ApiError = z.infer;
diff --git a/src/lib/convex-server.ts b/src/lib/convex-server.ts
index ce1d568..f296637 100644
--- a/src/lib/convex-server.ts
+++ b/src/lib/convex-server.ts
@@ -13,10 +13,14 @@ const convexFunctions = {
getUserAnalyses: "functions:getUserAnalyses",
getUserCoverLetters: "functions:getUserCoverLetters",
getUserResumes: "functions:getUserResumes",
+ getUserTailoredResumes: "functions:getUserTailoredResumes",
getUserStats: "functions:getUserStats",
saveAnalysis: "functions:saveAnalysis",
saveCoverLetter: "functions:saveCoverLetter",
saveResume: "functions:saveResume",
+ getTailoredResume: "functions:getTailoredResume",
+ getTailoredResumeById: "functions:getTailoredResumeById",
+ saveTailoredResume: "functions:saveTailoredResume",
} as const;
type ConvexClient = ConvexHttpClient & {
@@ -92,6 +96,21 @@ export interface SearchHistoryItem {
result: string;
}
+export interface TailoredResume {
+ _id: string;
+ _creationTime: number;
+ userId: string;
+ resumeHash: string;
+ jobDescriptionHash: string;
+ templateId: string;
+ jobTitle?: string;
+ companyName?: string;
+ resumeName?: string;
+ jobDescription?: string;
+ structuredData: string;
+ latexSource: string;
+}
+
// Helper: Generate hash for caching
export function generateHash(text: string): string {
return crypto.createHash("sha256").update(text).digest("hex");
@@ -302,6 +321,69 @@ export async function getUserCoverLetters(
}
}
+// ─── Tailored Resume Functions ───────────────────────────────────────
+
+export async function saveTailoredResume(
+ data: Omit
+): Promise {
+ const client = getClient();
+ const result = await client.mutation(convexFunctions.saveTailoredResume, data);
+ return result as unknown as TailoredResume;
+}
+
+export async function getTailoredResume(
+ userId: string,
+ resumeHash: string,
+ jobDescriptionHash: string,
+ templateId: string
+): Promise {
+ try {
+ const client = getClient();
+ const doc = await client.query(convexFunctions.getTailoredResume, {
+ userId,
+ resumeHash,
+ jobDescriptionHash,
+ templateId,
+ });
+ return doc as unknown as TailoredResume | null;
+ } catch (error) {
+ console.error("Error fetching tailored resume:", error);
+ return null;
+ }
+}
+
+export async function getTailoredResumeById(
+ tailoredResumeId: string
+): Promise {
+ try {
+ const client = getClient();
+ const doc = await client.query(convexFunctions.getTailoredResumeById, {
+ tailoredResumeId: tailoredResumeId as Id<"tailoredResumes">,
+ });
+ return doc as unknown as TailoredResume | null;
+ } catch (error) {
+ console.error("Error fetching tailored resume by id:", error);
+ return null;
+ }
+}
+
+export async function getUserTailoredResumes(
+ userId: string,
+ limit = 20
+): Promise {
+ try {
+ const client = getClient();
+ const docs = await client.query(convexFunctions.getUserTailoredResumes, {
+ userId,
+ limit,
+ });
+ return docs as unknown as TailoredResume[];
+ } catch (error) {
+ console.error("Error fetching user tailored resumes:", error);
+ return [];
+ }
+}
+
// ─── Stats ───────────────────────────────────────────────────────────
export async function getUserStats(userId: string): Promise<{
diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts
index 038988b..b8e682c 100644
--- a/src/lib/gemini.ts
+++ b/src/lib/gemini.ts
@@ -140,6 +140,54 @@ interface AnalysisOptions {
achievements?: string;
}
+export interface TailoredResumeSectionItem {
+ title: string;
+ subtitle?: string;
+ date?: string;
+ location?: string;
+ bullets: string[];
+}
+
+export interface TailoredResumeData {
+ fullName?: string;
+ email?: string;
+ phone?: string;
+ location?: string;
+ linkedin?: string;
+ github?: string;
+ website?: string;
+ summary: string;
+ skills: string[];
+ experience: TailoredResumeSectionItem[];
+ projects: TailoredResumeSectionItem[];
+ education: TailoredResumeSectionItem[];
+ certifications: string[];
+ additional: string[];
+ targetTitle?: string;
+ keywordsUsed: string[];
+}
+
+function createGeminiModel(analysisType: AnalysisType | 'tailoredResume') {
+ const modelName = process.env.MODEL_NAME || 'gemini-2.5-flash';
+ return genAI.getGenerativeModel({
+ model: modelName,
+ generationConfig: {
+ temperature: analysisType === 'coverLetter' ? 0.8 : 0.4,
+ maxOutputTokens: 16384,
+ },
+ });
+}
+
+function stripMarkdownJsonFence(input: string): string {
+ const trimmed = input.trim();
+ if (!trimmed.startsWith('```')) {
+ return trimmed;
+ }
+
+ const withoutStart = trimmed.replace(/^```(?:json)?\s*/i, '');
+ return withoutStart.replace(/\s*```$/, '').trim();
+}
+
export async function analyzeResume(
resumeText: string,
jobDescription: string,
@@ -150,16 +198,7 @@ export async function analyzeResume(
throw new Error('Google Gemini API key is not configured. Please set GOOGLE_API_KEY environment variable.');
}
- // Using gemini-2.5-flash (thinking model) - requires higher token limits
- // as thinking tokens count against maxOutputTokens
- const modelName = process.env.MODEL_NAME || 'gemini-2.5-flash';
- const model = genAI.getGenerativeModel({
- model: modelName,
- generationConfig: {
- temperature: analysisType === 'coverLetter' ? 0.8 : 0.4,
- maxOutputTokens: 16384, // Higher limit to accommodate thinking + response
- },
- });
+ const model = createGeminiModel(analysisType);
let prompt: string;
@@ -216,3 +255,87 @@ ${jobDescription}`;
throw error;
}
}
+
+export async function generateTailoredResumeData(
+ resumeText: string,
+ jobDescription: string
+): Promise {
+ if (!apiKey) {
+ throw new Error('Google Gemini API key is not configured. Please set GOOGLE_API_KEY environment variable.');
+ }
+
+ const model = createGeminiModel('tailoredResume');
+ const prompt = `You are an expert ATS resume writer.
+Generate tailored resume content from the SOURCE RESUME and JOB DESCRIPTION.
+
+CRITICAL RULES:
+1) Use only information from SOURCE RESUME. Do not invent employers, dates, degrees, certifications, metrics, or technologies.
+2) Focus on relevance to the JOB DESCRIPTION keywords and responsibilities.
+3) Keep bullets concise and impact-focused.
+4) Return ONLY valid JSON, no markdown, no commentary.
+
+Return exactly this shape:
+{
+ "fullName": "string or empty",
+ "email": "string or empty",
+ "phone": "string or empty",
+ "location": "string or empty",
+ "linkedin": "string or empty",
+ "github": "string or empty",
+ "website": "string or empty",
+ "summary": "2-4 line professional summary",
+ "skills": ["max 18 skills ordered by relevance"],
+ "experience": [
+ {
+ "title": "Role title",
+ "subtitle": "Company",
+ "date": "Date range",
+ "location": "Location",
+ "bullets": ["3-5 bullets"]
+ }
+ ],
+ "projects": [
+ {
+ "title": "Project name",
+ "subtitle": "Tech stack or context",
+ "date": "Date range or empty",
+ "location": "Location or empty",
+ "bullets": ["2-4 bullets"]
+ }
+ ],
+ "education": [
+ {
+ "title": "Degree",
+ "subtitle": "Institution",
+ "date": "Date range",
+ "location": "Location",
+ "bullets": ["optional bullets, may be empty"]
+ }
+ ],
+ "certifications": ["optional"],
+ "additional": ["optional extras like awards/publications"],
+ "targetTitle": "best-fit role title from job description",
+ "keywordsUsed": ["important JD keywords reflected in the resume content"]
+}
+
+SOURCE RESUME:
+${resumeText}
+
+JOB DESCRIPTION:
+${jobDescription}`;
+
+ const result = await model.generateContent(prompt);
+ const text = result.response.text();
+
+ if (!text || text.trim() === '') {
+ throw new Error('AI returned an empty response. Please try again.');
+ }
+
+ const normalized = stripMarkdownJsonFence(text);
+ try {
+ return JSON.parse(normalized) as TailoredResumeData;
+ } catch (error) {
+ console.error('Failed to parse tailored resume JSON', { error, text: normalized });
+ throw new Error('AI returned invalid structured resume data. Please try again.');
+ }
+}
diff --git a/src/lib/resume-latex.ts b/src/lib/resume-latex.ts
new file mode 100644
index 0000000..925f91a
--- /dev/null
+++ b/src/lib/resume-latex.ts
@@ -0,0 +1,216 @@
+import type { TailoredResumeData, TailoredResumeSectionItem } from '@/lib/gemini';
+
+export const RESUME_TEMPLATE_IDS = ['awesome-classic', 'deedy-modern', 'sb2nov-ats'] as const;
+
+export type ResumeTemplateId = (typeof RESUME_TEMPLATE_IDS)[number];
+
+export interface ResumeTemplateOption {
+ id: ResumeTemplateId;
+ name: string;
+ description: string;
+ atsFriendly: boolean;
+}
+
+export const RESUME_TEMPLATE_OPTIONS: ResumeTemplateOption[] = [
+ {
+ id: 'awesome-classic',
+ name: 'Awesome Classic',
+ description: 'Two-line section headers with polished spacing and readable hierarchy.',
+ atsFriendly: true,
+ },
+ {
+ id: 'deedy-modern',
+ name: 'Deedy Modern',
+ description: 'Dense one-page format inspired by Deedy-style technical resumes.',
+ atsFriendly: true,
+ },
+ {
+ id: 'sb2nov-ats',
+ name: 'SB2Nov ATS',
+ description: 'Simple ATS-friendly formatting with minimal visual noise.',
+ atsFriendly: true,
+ },
+];
+
+function cleanList(values: string[] | undefined, maxItems: number): string[] {
+ return (values ?? [])
+ .map((value) => value.trim())
+ .filter(Boolean)
+ .slice(0, maxItems);
+}
+
+function cleanSectionItems(items: TailoredResumeSectionItem[] | undefined, maxItems: number): TailoredResumeSectionItem[] {
+ return (items ?? [])
+ .map((item) => ({
+ title: item.title?.trim() ?? '',
+ subtitle: item.subtitle?.trim() || undefined,
+ date: item.date?.trim() || undefined,
+ location: item.location?.trim() || undefined,
+ bullets: cleanList(item.bullets, 6),
+ }))
+ .filter((item) => item.title.length > 0)
+ .slice(0, maxItems);
+}
+
+export function escapeLatex(input: string): string {
+ return input
+ .replace(/\\/g, '\\textbackslash{}')
+ .replace(/&/g, '\\&')
+ .replace(/%/g, '\\%')
+ .replace(/\$/g, '\\$')
+ .replace(/#/g, '\\#')
+ .replace(/_/g, '\\_')
+ .replace(/{/g, '\\{')
+ .replace(/}/g, '\\}')
+ .replace(/~/g, '\\textasciitilde{}')
+ .replace(/\^/g, '\\textasciicircum{}');
+}
+
+function renderBullets(items: string[]): string {
+ if (items.length === 0) {
+ return '';
+ }
+
+ const rows = items.map((item) => `\\item ${escapeLatex(item)}`).join('\n');
+ return `\\begin{itemize}[leftmargin=*, itemsep=2pt, topsep=3pt]\n${rows}\n\\end{itemize}`;
+}
+
+function renderEntry(entry: TailoredResumeSectionItem): string {
+ const headerParts = [entry.title, entry.subtitle].filter(Boolean).map((value) => `\\textbf{${escapeLatex(value as string)}}`);
+ const left = headerParts.join(' -- ');
+ const rightParts = [entry.location, entry.date].filter(Boolean).map((value) => escapeLatex(value as string));
+ const right = rightParts.join(' | ');
+
+ const header = right.length > 0
+ ? `\\textbf{${escapeLatex(entry.title)}} ${entry.subtitle ? `\\textit{${escapeLatex(entry.subtitle)}}` : ''} \\hfill ${right}`
+ : left;
+
+ const bullets = renderBullets(entry.bullets);
+ return `${header}\n${bullets}`.trim();
+}
+
+function renderSection(title: string, content: string): string {
+ if (!content.trim()) {
+ return '';
+ }
+
+ return `\\section*{${escapeLatex(title)}}\n${content}`;
+}
+
+function renderSkills(skills: string[]): string {
+ if (skills.length === 0) {
+ return '';
+ }
+
+ return escapeLatex(skills.join(' | '));
+}
+
+function renderContact(data: TailoredResumeData): string {
+ const parts = [data.email, data.phone, data.location, data.linkedin, data.github, data.website]
+ .filter((value): value is string => Boolean(value && value.trim()))
+ .map((value) => escapeLatex(value.trim()));
+
+ return parts.join(' \\textbar{} ');
+}
+
+function renderBody(data: TailoredResumeData): string {
+ const summary = data.summary.trim();
+ const skills = cleanList(data.skills, 18);
+ const experience = cleanSectionItems(data.experience, 5);
+ const projects = cleanSectionItems(data.projects, 4);
+ const education = cleanSectionItems(data.education, 3);
+ const certifications = cleanList(data.certifications, 8);
+ const additional = cleanList(data.additional, 8);
+
+ const sections = [
+ summary ? renderSection('Summary', escapeLatex(summary)) : '',
+ renderSection('Skills', renderSkills(skills)),
+ renderSection('Experience', experience.map(renderEntry).join('\n\n')),
+ renderSection('Projects', projects.map(renderEntry).join('\n\n')),
+ renderSection('Education', education.map(renderEntry).join('\n\n')),
+ certifications.length > 0 ? renderSection('Certifications', certifications.map((item) => `\\textbullet{} ${escapeLatex(item)}`).join('\\\\\n')) : '',
+ additional.length > 0 ? renderSection('Additional', additional.map((item) => `\\textbullet{} ${escapeLatex(item)}`).join('\\\\\n')) : '',
+ ].filter(Boolean);
+
+ return sections.join('\n\n');
+}
+
+function buildTemplatePreamble(templateId: ResumeTemplateId): string {
+ if (templateId === 'deedy-modern') {
+ return `\\documentclass[11pt]{article}
+\\usepackage[margin=0.65in]{geometry}
+\\usepackage{enumitem}
+\\usepackage[T1]{fontenc}
+\\usepackage[utf8]{inputenc}
+\\usepackage{lmodern}
+\\setlength{\\parindent}{0pt}
+\\setlength{\\parskip}{5pt}
+\\pagenumbering{gobble}
+\\begin{document}`;
+ }
+
+ if (templateId === 'sb2nov-ats') {
+ return `\\documentclass[11pt]{article}
+\\usepackage[margin=0.75in]{geometry}
+\\usepackage{enumitem}
+\\usepackage[T1]{fontenc}
+\\usepackage[utf8]{inputenc}
+\\usepackage{helvet}
+\\renewcommand{\\familydefault}{\\sfdefault}
+\\setlength{\\parindent}{0pt}
+\\setlength{\\parskip}{4pt}
+\\pagenumbering{gobble}
+\\begin{document}`;
+ }
+
+ return `\\documentclass[11pt]{article}
+\\usepackage[margin=0.7in]{geometry}
+\\usepackage{enumitem}
+\\usepackage[T1]{fontenc}
+\\usepackage[utf8]{inputenc}
+\\usepackage{lmodern}
+\\setlength{\\parindent}{0pt}
+\\setlength{\\parskip}{5pt}
+\\pagenumbering{gobble}
+\\begin{document}`;
+}
+
+export function buildLatexResume(templateId: ResumeTemplateId, rawData: TailoredResumeData): string {
+ const data: TailoredResumeData = {
+ fullName: rawData.fullName?.trim(),
+ email: rawData.email?.trim(),
+ phone: rawData.phone?.trim(),
+ location: rawData.location?.trim(),
+ linkedin: rawData.linkedin?.trim(),
+ github: rawData.github?.trim(),
+ website: rawData.website?.trim(),
+ summary: rawData.summary?.trim() ?? '',
+ skills: rawData.skills ?? [],
+ experience: rawData.experience ?? [],
+ projects: rawData.projects ?? [],
+ education: rawData.education ?? [],
+ certifications: rawData.certifications ?? [],
+ additional: rawData.additional ?? [],
+ targetTitle: rawData.targetTitle?.trim(),
+ keywordsUsed: rawData.keywordsUsed ?? [],
+ };
+
+ const name = escapeLatex(data.fullName || 'Candidate Name');
+ const contact = renderContact(data);
+ const headline = data.targetTitle?.trim() ? `\\textit{${escapeLatex(data.targetTitle.trim())}}` : '';
+ const body = renderBody(data);
+
+ const preamble = buildTemplatePreamble(templateId);
+
+ return `${preamble}
+\\begin{center}
+{\\LARGE \\textbf{${name}}}\\\\
+${headline}
+${contact ? `${contact}\\\\` : ''}
+\\end{center}
+
+${body}
+
+\\end{document}
+`;
+}
diff --git a/test/app/api/generate-resume-latex/route.test.ts b/test/app/api/generate-resume-latex/route.test.ts
new file mode 100644
index 0000000..65973fc
--- /dev/null
+++ b/test/app/api/generate-resume-latex/route.test.ts
@@ -0,0 +1,105 @@
+import { NextRequest } from 'next/server';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { POST } from '@/app/api/generate-resume-latex/route';
+import { checkRateLimit, getAuthenticatedUser } from '@/lib/auth';
+import { getTailoredResume, saveTailoredResume } from '@/lib/convex-server';
+import { generateTailoredResumeData } from '@/lib/gemini';
+
+vi.mock('@/lib/auth', () => ({
+ getAuthenticatedUser: vi.fn(),
+ checkRateLimit: vi.fn(),
+}));
+
+vi.mock('@/lib/gemini', () => ({
+ generateTailoredResumeData: vi.fn(),
+}));
+
+vi.mock('@/lib/convex-server', () => ({
+ getTailoredResume: vi.fn(),
+ saveTailoredResume: vi.fn(),
+ generateHash: vi.fn((value: string) => `hash-${value.length}`),
+}));
+
+describe('/api/generate-resume-latex', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getAuthenticatedUser).mockResolvedValue('u1');
+ vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, remaining: 10, resetIn: 1000 });
+ vi.mocked(getTailoredResume).mockResolvedValue(null);
+ vi.mocked(generateTailoredResumeData).mockResolvedValue({
+ summary: 'Software engineer focused on distributed systems.',
+ skills: ['TypeScript', 'Next.js', 'Node.js'],
+ experience: [],
+ projects: [],
+ education: [],
+ certifications: [],
+ additional: [],
+ keywordsUsed: ['scalability'],
+ targetTitle: 'Senior Software Engineer',
+ });
+ vi.mocked(saveTailoredResume).mockResolvedValue({ _id: 'tr-1' } as never);
+ });
+
+ it('returns 400 for invalid payload', async () => {
+ const req = new NextRequest('http://localhost/api/generate-resume-latex', {
+ method: 'POST',
+ body: JSON.stringify({}),
+ });
+
+ const res = await POST(req);
+ const data = await res.json();
+
+ expect(res.status).toBe(400);
+ expect(data.code).toBe('VALIDATION_ERROR');
+ });
+
+ it('returns cached tailored resume when available', async () => {
+ vi.mocked(getTailoredResume).mockResolvedValue({
+ _id: 'tr-cache',
+ templateId: 'awesome-classic',
+ latexSource: '\\documentclass{article}',
+ structuredData: JSON.stringify({ summary: 'cached' }),
+ } as never);
+
+ const req = new NextRequest('http://localhost/api/generate-resume-latex', {
+ method: 'POST',
+ body: JSON.stringify({
+ resumeText: 'resume',
+ jobDescription: 'job',
+ templateId: 'awesome-classic',
+ }),
+ });
+
+ const res = await POST(req);
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data.cached).toBe(true);
+ expect(data.documentId).toBe('tr-cache');
+ expect(generateTailoredResumeData).not.toHaveBeenCalled();
+ });
+
+ it('generates and saves a tailored resume', async () => {
+ const req = new NextRequest('http://localhost/api/generate-resume-latex', {
+ method: 'POST',
+ body: JSON.stringify({
+ resumeText: 'resume text',
+ jobDescription: 'job description',
+ templateId: 'deedy-modern',
+ resumeName: 'Resume.pdf',
+ }),
+ });
+
+ const res = await POST(req);
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data.cached).toBe(false);
+ expect(data.templateId).toBe('deedy-modern');
+ expect(data.documentId).toBe('tr-1');
+ expect(data.latexSource).toContain('documentclass');
+ expect(generateTailoredResumeData).toHaveBeenCalledTimes(1);
+ expect(saveTailoredResume).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/test/lib/gemini.test.ts b/test/lib/gemini.test.ts
index 64caf23..9108fe5 100644
--- a/test/lib/gemini.test.ts
+++ b/test/lib/gemini.test.ts
@@ -1,6 +1,6 @@
import { beforeEach,describe, expect, it, vi } from 'vitest';
-import { analyzeResume } from '@/lib/gemini';
+import { analyzeResume, generateTailoredResumeData } from '@/lib/gemini';
const { mockGenerateContent, mockGetGenerativeModel } = vi.hoisted(() => ({
mockGenerateContent: vi.fn(),
@@ -87,4 +87,25 @@ describe('gemini', () => {
expect(prompt).toContain('300 words');
expect(prompt).toContain('4 paragraphs');
});
+
+ it('should parse structured JSON for tailored resume generation', async () => {
+ mockGenerateContent.mockResolvedValue({
+ response: {
+ text: () => JSON.stringify({
+ summary: 'summary',
+ skills: ['TypeScript'],
+ experience: [],
+ projects: [],
+ education: [],
+ certifications: [],
+ additional: [],
+ keywordsUsed: ['typescript'],
+ }),
+ },
+ });
+
+ const result = await generateTailoredResumeData('resume', 'job');
+ expect(result.summary).toBe('summary');
+ expect(result.skills).toEqual(['TypeScript']);
+ });
});
diff --git a/test/lib/resume-latex.test.ts b/test/lib/resume-latex.test.ts
new file mode 100644
index 0000000..5434156
--- /dev/null
+++ b/test/lib/resume-latex.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildLatexResume, escapeLatex } from '@/lib/resume-latex';
+
+describe('resume-latex', () => {
+ it('escapes LaTeX special characters', () => {
+ const value = '50% growth & $1000 #1 _dev_';
+ const escaped = escapeLatex(value);
+
+ expect(escaped).toContain('\\%');
+ expect(escaped).toContain('\\&');
+ expect(escaped).toContain('\\$');
+ expect(escaped).toContain('\\#');
+ expect(escaped).toContain('\\_');
+ });
+
+ it('builds latex document for template', () => {
+ const output = buildLatexResume('awesome-classic', {
+ fullName: 'Ada Lovelace',
+ email: 'ada@example.com',
+ summary: 'Engineer building reliable systems.',
+ skills: ['TypeScript', 'Node.js'],
+ experience: [
+ {
+ title: 'Software Engineer',
+ subtitle: 'Acme',
+ date: '2022-2025',
+ location: 'Remote',
+ bullets: ['Improved API latency by 30%'],
+ },
+ ],
+ projects: [],
+ education: [],
+ certifications: [],
+ additional: [],
+ keywordsUsed: ['api'],
+ targetTitle: 'Senior Engineer',
+ });
+
+ expect(output).toContain('\\documentclass');
+ expect(output).toContain('Ada Lovelace');
+ expect(output).toContain('Software Engineer');
+ expect(output).toContain('\\section*{Summary}');
+ expect(output).toContain('\\end{document}');
+ });
+});
From 0fd3b905f3021d8d7fd130e0761b2d0d80864ba9 Mon Sep 17 00:00:00 2001
From: Aditya Mer
Date: Sun, 22 Feb 2026 15:28:49 +0530
Subject: [PATCH 2/7] feat(resume): add latex render/fix APIs and resume
builder backend
---
convex/functions.ts | 48 +++++++++
convex/schema.ts | 8 +-
src/app/api/fix-latex/route.ts | 67 ++++++++++++
src/app/api/generate-resume-latex/route.ts | 75 +++++++++++--
src/app/api/render-latex/route.ts | 59 ++++++++++
src/app/api/resume-builder/[slug]/route.ts | 119 +++++++++++++++++++++
src/lib/contracts/api.ts | 13 ++-
src/lib/convex-server.ts | 30 +++++-
src/lib/gemini.ts | 52 ++++++++-
src/lib/latex-render.ts | 67 ++++++++++++
src/lib/resume-latex.ts | 117 ++++++++++++++++++--
src/types/domain.ts | 18 +++-
test/app/api/render-latex/route.test.ts | 66 ++++++++++++
13 files changed, 718 insertions(+), 21 deletions(-)
create mode 100644 src/app/api/fix-latex/route.ts
create mode 100644 src/app/api/render-latex/route.ts
create mode 100644 src/app/api/resume-builder/[slug]/route.ts
create mode 100644 src/lib/latex-render.ts
create mode 100644 test/app/api/render-latex/route.test.ts
diff --git a/convex/functions.ts b/convex/functions.ts
index 12e84ce..84010a8 100644
--- a/convex/functions.ts
+++ b/convex/functions.ts
@@ -259,6 +259,11 @@ export const saveTailoredResume = mutation({
jobDescription: v.optional(v.string()),
structuredData: v.string(),
latexSource: v.string(),
+ builderSlug: v.optional(v.string()),
+ version: v.optional(v.number()),
+ sourceAnalysisId: v.optional(v.string()),
+ customTemplateName: v.optional(v.string()),
+ customTemplateSource: v.optional(v.string()),
},
handler: async (ctx, args) => {
const id = await ctx.db.insert("tailoredResumes", args);
@@ -322,6 +327,26 @@ export const getUserTailoredResumes = query({
},
});
+export const getTailoredResumeVersionsBySlug = query({
+ args: {
+ userId: v.string(),
+ builderSlug: v.string(),
+ limit: v.optional(v.number()),
+ },
+ handler: async (ctx, args) => {
+ const limit = args.limit ?? 30;
+ const docs = await ctx.db
+ .query("tailoredResumes")
+ .withIndex("by_userId_builderSlug", (q) =>
+ q.eq("userId", args.userId)
+ )
+ .filter((q) => q.eq(q.field("builderSlug"), args.builderSlug))
+ .order("desc")
+ .take(limit);
+ return docs;
+ },
+});
+
// ─── Stats ───────────────────────────────────────────────────────────
export const getUserStats = query({
@@ -397,6 +422,12 @@ export const getSearchHistory = query({
.order("desc")
.take(limit * 2);
+ const tailoredResumesRaw = await ctx.db
+ .query("tailoredResumes")
+ .withIndex("by_userId", (q) => q.eq("userId", args.userId))
+ .order("desc")
+ .take(limit * 2);
+
const analyses = cursorTime
? analysesRaw.filter((doc) => doc._creationTime < cursorTime)
: analysesRaw;
@@ -405,6 +436,10 @@ export const getSearchHistory = query({
? coverLettersRaw.filter((doc) => doc._creationTime < cursorTime)
: coverLettersRaw;
+ const tailoredResumes = cursorTime
+ ? tailoredResumesRaw.filter((doc) => doc._creationTime < cursorTime)
+ : tailoredResumesRaw;
+
const history = [
...analyses.map((doc) => ({
id: doc._id,
@@ -426,6 +461,19 @@ export const getSearchHistory = query({
createdAt: new Date(doc._creationTime).toISOString(),
result: doc.result,
})),
+ ...tailoredResumes.map((doc) => ({
+ id: doc._id,
+ type: "resume" as const,
+ companyName: doc.companyName,
+ resumeName: doc.resumeName,
+ jobTitle: doc.jobTitle,
+ jobDescription: doc.jobDescription,
+ templateId: doc.templateId,
+ builderSlug: doc.builderSlug,
+ version: doc.version,
+ createdAt: new Date(doc._creationTime).toISOString(),
+ result: doc.latexSource,
+ })),
];
history.sort(
diff --git a/convex/schema.ts b/convex/schema.ts
index d4a1295..ab8f0ea 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -52,7 +52,13 @@ export default defineSchema({
jobDescription: v.optional(v.string()),
structuredData: v.string(),
latexSource: v.string(),
+ builderSlug: v.optional(v.string()),
+ version: v.optional(v.number()),
+ sourceAnalysisId: v.optional(v.string()),
+ customTemplateName: v.optional(v.string()),
+ customTemplateSource: v.optional(v.string()),
})
.index("by_userId", ["userId"])
- .index("by_lookup", ["userId", "resumeHash", "jobDescriptionHash", "templateId"]),
+ .index("by_lookup", ["userId", "resumeHash", "jobDescriptionHash", "templateId"])
+ .index("by_userId_builderSlug", ["userId", "builderSlug"]),
});
diff --git a/src/app/api/fix-latex/route.ts b/src/app/api/fix-latex/route.ts
new file mode 100644
index 0000000..0500357
--- /dev/null
+++ b/src/app/api/fix-latex/route.ts
@@ -0,0 +1,67 @@
+import { randomUUID } from 'crypto';
+import { NextRequest } from 'next/server';
+import { z } from 'zod';
+
+import { apiError, apiSuccess } from '@/lib/api-response';
+import { withTimeout } from '@/lib/async-timeout';
+import { checkRateLimit, getAuthenticatedUser } from '@/lib/auth';
+import { fixLatexCompilationError } from '@/lib/gemini';
+
+const fixLatexRequestSchema = z.object({
+ latexSource: z.string().trim().min(1).max(180000),
+ compileLog: z.string().max(20000).optional(),
+});
+
+const LATEX_FIX_TIMEOUT_MS = Number(process.env.LATEX_FIX_TIMEOUT_MS || 30000);
+
+export async function POST(request: NextRequest) {
+ const requestId = request.headers.get('x-request-id') ?? randomUUID();
+
+ try {
+ const userId = await getAuthenticatedUser();
+ if (!userId) {
+ return apiError(requestId, 401, 'AUTH_REQUIRED', 'Authentication required');
+ }
+
+ const rateLimit = await checkRateLimit(`fix-latex-${userId}`, { windowMs: 60000, maxRequests: 12 });
+ if (!rateLimit.allowed) {
+ return apiError(
+ requestId,
+ 429,
+ 'RATE_LIMITED',
+ `Rate limit exceeded. Try again in ${Math.ceil(rateLimit.resetIn / 1000)} seconds.`,
+ );
+ }
+
+ const parse = fixLatexRequestSchema.safeParse(await request.json());
+ if (!parse.success) {
+ return apiError(requestId, 400, 'VALIDATION_ERROR', 'Invalid request payload', parse.error.flatten());
+ }
+
+ const { latexSource, compileLog } = parse.data;
+ const fixedLatex = await withTimeout(
+ fixLatexCompilationError(latexSource, compileLog),
+ LATEX_FIX_TIMEOUT_MS,
+ 'LaTeX AI fix timed out. Please try again.',
+ );
+
+ return apiSuccess({ fixedLatex, requestId });
+ } catch (error) {
+ console.error('LaTeX AI fix error', { requestId, error });
+
+ if (error instanceof Error) {
+ if (error.message.includes('timed out')) {
+ return apiError(requestId, 504, 'LATEX_FIX_TIMEOUT', error.message);
+ }
+ if (error.message.includes('quota') || error.message.includes('rate')) {
+ return apiError(requestId, 429, 'UPSTREAM_RATE_LIMITED', 'AI rate limit exceeded. Please try again shortly.');
+ }
+ if (error.message.includes('API key')) {
+ return apiError(requestId, 500, 'AI_CONFIG_ERROR', 'AI service configuration error. Please contact support.');
+ }
+ return apiError(requestId, 500, 'LATEX_FIX_FAILED', error.message);
+ }
+
+ return apiError(requestId, 500, 'LATEX_FIX_FAILED', 'Failed to auto-fix LaTeX');
+ }
+}
diff --git a/src/app/api/generate-resume-latex/route.ts b/src/app/api/generate-resume-latex/route.ts
index 7e56537..32a5ae2 100644
--- a/src/app/api/generate-resume-latex/route.ts
+++ b/src/app/api/generate-resume-latex/route.ts
@@ -5,10 +5,15 @@ import { apiError, apiSuccess } from '@/lib/api-response';
import { withTimeout } from '@/lib/async-timeout';
import { checkRateLimit, getAuthenticatedUser } from '@/lib/auth';
import { tailoredResumeRequestSchema } from '@/lib/contracts/api';
-import { generateHash, getTailoredResume, saveTailoredResume } from '@/lib/convex-server';
+import {
+ generateHash,
+ getTailoredResume,
+ getTailoredResumeVersionsBySlug,
+ saveTailoredResume,
+} from '@/lib/convex-server';
import { generateTailoredResumeData } from '@/lib/gemini';
import { getIdempotentResponse, setIdempotentResponse } from '@/lib/idempotency';
-import { buildLatexResume } from '@/lib/resume-latex';
+import { buildLatexResume, buildLatexResumeFromCustomTemplate } from '@/lib/resume-latex';
const RESUME_ROUTE_TIMEOUT_MS = Number(process.env.RESUME_ROUTE_TIMEOUT_MS || 45000);
@@ -42,9 +47,21 @@ export async function POST(request: NextRequest) {
jobDescription,
templateId,
resumeName,
+ builderSlug,
+ sourceAnalysisId,
+ customTemplateName,
+ customTemplateLatex,
forceRegenerate,
idempotencyKey,
} = payload;
+ const isCustomTemplate = templateId === 'custom';
+ if (isCustomTemplate && !customTemplateLatex) {
+ return apiError(requestId, 400, 'VALIDATION_ERROR', 'customTemplateLatex is required for custom template mode');
+ }
+
+ const templateLookupId = isCustomTemplate
+ ? `custom:${generateHash(customTemplateLatex ?? '')}`
+ : templateId;
const idemHeader = request.headers.get('idempotency-key')?.trim();
const effectiveIdempotencyKey = idempotencyKey ?? idemHeader;
@@ -60,7 +77,7 @@ export async function POST(request: NextRequest) {
const jobDescriptionHash = generateHash(jobDescription);
if (!forceRegenerate) {
- const cached = await getTailoredResume(userId, resumeHash, jobDescriptionHash, templateId);
+ const cached = await getTailoredResume(userId, resumeHash, jobDescriptionHash, templateLookupId);
if (cached) {
let structuredData: Record = {};
try {
@@ -69,13 +86,42 @@ export async function POST(request: NextRequest) {
structuredData = {};
}
+ let documentId = cached._id;
+ if (builderSlug) {
+ try {
+ const versions = await getTailoredResumeVersionsBySlug(userId, builderSlug, 100);
+ const latestVersion = versions.reduce((max, item) => Math.max(max, item.version ?? 0), 0);
+ const saved = await saveTailoredResume({
+ userId,
+ resumeHash,
+ jobDescriptionHash,
+ templateId: templateLookupId,
+ jobTitle: cached.jobTitle,
+ companyName: cached.companyName,
+ resumeName,
+ jobDescription,
+ structuredData: cached.structuredData,
+ latexSource: cached.latexSource,
+ builderSlug,
+ version: latestVersion + 1,
+ sourceAnalysisId,
+ customTemplateName: customTemplateName || cached.customTemplateName,
+ customTemplateSource: customTemplateLatex || cached.customTemplateSource,
+ });
+ documentId = saved._id;
+ } catch (saveError) {
+ console.error('Error saving cached tailored resume version', { requestId, error: saveError });
+ }
+ }
+
const response = {
latexSource: cached.latexSource,
structuredData,
- templateId: cached.templateId,
+ templateId,
cached: true,
source: 'database' as const,
- documentId: cached._id,
+ documentId,
+ builderSlug,
requestId,
};
@@ -93,20 +139,33 @@ export async function POST(request: NextRequest) {
'Resume generation timed out. Please try again.',
);
- const latexSource = buildLatexResume(templateId, structuredData);
+ const latexSource = isCustomTemplate
+ ? buildLatexResumeFromCustomTemplate(customTemplateLatex ?? '', structuredData)
+ : buildLatexResume(templateId, structuredData);
let documentId: string | undefined;
+ let resolvedVersion = 1;
try {
+ if (builderSlug) {
+ const versions = await getTailoredResumeVersionsBySlug(userId, builderSlug, 100);
+ const latestVersion = versions.reduce((max, item) => Math.max(max, item.version ?? 0), 0);
+ resolvedVersion = latestVersion + 1;
+ }
const saved = await saveTailoredResume({
userId,
resumeHash,
jobDescriptionHash,
- templateId,
+ templateId: templateLookupId,
jobTitle: structuredData.targetTitle,
resumeName,
jobDescription,
structuredData: JSON.stringify(structuredData),
latexSource,
+ builderSlug,
+ version: builderSlug ? resolvedVersion : undefined,
+ sourceAnalysisId,
+ customTemplateName,
+ customTemplateSource: customTemplateLatex,
});
documentId = saved._id;
} catch (saveError) {
@@ -119,6 +178,8 @@ export async function POST(request: NextRequest) {
templateId,
cached: false,
documentId,
+ builderSlug,
+ version: builderSlug ? resolvedVersion : undefined,
requestId,
};
diff --git a/src/app/api/render-latex/route.ts b/src/app/api/render-latex/route.ts
new file mode 100644
index 0000000..a685f9b
--- /dev/null
+++ b/src/app/api/render-latex/route.ts
@@ -0,0 +1,59 @@
+import { randomUUID } from 'crypto';
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+
+import { apiError } from '@/lib/api-response';
+import { getAuthenticatedUser } from '@/lib/auth';
+import {
+ buildLatexCompileUrl,
+ compileLatexViaUpload,
+ shouldUseLatexUploadMode,
+} from '@/lib/latex-render';
+
+const renderRequestSchema = z.object({
+ latexSource: z.string().trim().min(1).max(120000),
+});
+
+export async function POST(request: NextRequest) {
+ const requestId = request.headers.get('x-request-id') ?? randomUUID();
+
+ try {
+ const userId = await getAuthenticatedUser();
+ if (!userId) {
+ return apiError(requestId, 401, 'AUTH_REQUIRED', 'Authentication required');
+ }
+
+ const parse = renderRequestSchema.safeParse(await request.json());
+ if (!parse.success) {
+ return apiError(requestId, 400, 'VALIDATION_ERROR', 'Invalid request payload', parse.error.flatten());
+ }
+
+ const latexSource = parse.data.latexSource;
+ const upstream = shouldUseLatexUploadMode(latexSource)
+ ? await compileLatexViaUpload(latexSource)
+ : await fetch(buildLatexCompileUrl(latexSource), {
+ method: 'GET',
+ headers: { Accept: 'application/pdf' },
+ cache: 'no-store',
+ });
+
+ const payload = await upstream.arrayBuffer();
+
+ if (!upstream.ok) {
+ const text = Buffer.from(payload).toString('utf-8').slice(0, 1200);
+ return apiError(requestId, 422, 'LATEX_COMPILE_FAILED', 'LaTeX compilation failed', { log: text || 'Compilation error' });
+ }
+
+ return new NextResponse(payload, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/pdf',
+ 'Cache-Control': 'no-store',
+ 'x-request-id': requestId,
+ },
+ });
+ } catch (error) {
+ console.error('Latex render error', { requestId, error });
+ return apiError(requestId, 500, 'LATEX_RENDER_FAILED', 'Failed to render LaTeX');
+ }
+}
diff --git a/src/app/api/resume-builder/[slug]/route.ts b/src/app/api/resume-builder/[slug]/route.ts
new file mode 100644
index 0000000..3cc2d63
--- /dev/null
+++ b/src/app/api/resume-builder/[slug]/route.ts
@@ -0,0 +1,119 @@
+import { randomUUID } from 'crypto';
+import { NextRequest } from 'next/server';
+import { z } from 'zod';
+
+import { apiError, apiSuccess } from '@/lib/api-response';
+import { getAuthenticatedUser } from '@/lib/auth';
+import {
+ getTailoredResumeVersionsBySlug,
+ saveTailoredResume,
+} from '@/lib/convex-server';
+
+const saveVersionSchema = z.object({
+ latexSource: z.string().trim().min(1).max(180000),
+});
+
+export async function GET(
+ request: NextRequest,
+ context: { params: Promise<{ slug: string }> },
+) {
+ const requestId = request.headers.get('x-request-id') ?? randomUUID();
+
+ try {
+ const userId = await getAuthenticatedUser();
+ if (!userId) {
+ return apiError(requestId, 401, 'AUTH_REQUIRED', 'Authentication required');
+ }
+
+ const { slug } = await context.params;
+ if (!slug) {
+ return apiError(requestId, 400, 'VALIDATION_ERROR', 'slug is required');
+ }
+
+ const versions = await getTailoredResumeVersionsBySlug(userId, slug, 100);
+ if (versions.length === 0) {
+ return apiError(requestId, 404, 'NOT_FOUND', 'Resume builder session not found');
+ }
+
+ const sorted = [...versions].sort((a, b) => (b.version ?? 0) - (a.version ?? 0));
+ return apiSuccess({
+ slug,
+ latestVersion: sorted[0]?.version ?? 1,
+ versions: sorted.map((item) => ({
+ id: item._id,
+ version: item.version ?? 1,
+ latexSource: item.latexSource,
+ templateId: item.templateId.startsWith('custom:') ? 'custom' : item.templateId,
+ resumeName: item.resumeName,
+ jobDescription: item.jobDescription,
+ sourceAnalysisId: item.sourceAnalysisId,
+ customTemplateName: item.customTemplateName,
+ createdAt: new Date(item._creationTime).toISOString(),
+ })),
+ requestId,
+ });
+ } catch (error) {
+ console.error('Error fetching resume builder session', { requestId, error });
+ return apiError(requestId, 500, 'RESUME_BUILDER_FETCH_FAILED', 'Failed to fetch resume builder session');
+ }
+}
+
+export async function POST(
+ request: NextRequest,
+ context: { params: Promise<{ slug: string }> },
+) {
+ const requestId = request.headers.get('x-request-id') ?? randomUUID();
+
+ try {
+ const userId = await getAuthenticatedUser();
+ if (!userId) {
+ return apiError(requestId, 401, 'AUTH_REQUIRED', 'Authentication required');
+ }
+
+ const { slug } = await context.params;
+ if (!slug) {
+ return apiError(requestId, 400, 'VALIDATION_ERROR', 'slug is required');
+ }
+
+ const parse = saveVersionSchema.safeParse(await request.json());
+ if (!parse.success) {
+ return apiError(requestId, 400, 'VALIDATION_ERROR', 'Invalid request payload', parse.error.flatten());
+ }
+
+ const versions = await getTailoredResumeVersionsBySlug(userId, slug, 100);
+ if (versions.length === 0) {
+ return apiError(requestId, 404, 'NOT_FOUND', 'Resume builder session not found');
+ }
+
+ const latest = versions.reduce((best, current) => ((current.version ?? 0) > (best.version ?? 0) ? current : best), versions[0]);
+ const nextVersion = (latest.version ?? 1) + 1;
+
+ const saved = await saveTailoredResume({
+ userId,
+ resumeHash: latest.resumeHash,
+ jobDescriptionHash: latest.jobDescriptionHash,
+ templateId: latest.templateId,
+ jobTitle: latest.jobTitle,
+ companyName: latest.companyName,
+ resumeName: latest.resumeName,
+ jobDescription: latest.jobDescription,
+ structuredData: latest.structuredData,
+ latexSource: parse.data.latexSource,
+ builderSlug: slug,
+ version: nextVersion,
+ sourceAnalysisId: latest.sourceAnalysisId,
+ customTemplateName: latest.customTemplateName,
+ customTemplateSource: latest.customTemplateSource,
+ });
+
+ return apiSuccess({
+ id: saved._id,
+ version: nextVersion,
+ createdAt: new Date(saved._creationTime).toISOString(),
+ requestId,
+ });
+ } catch (error) {
+ console.error('Error saving resume builder version', { requestId, error });
+ return apiError(requestId, 500, 'RESUME_BUILDER_SAVE_FAILED', 'Failed to save resume version');
+ }
+}
diff --git a/src/lib/contracts/api.ts b/src/lib/contracts/api.ts
index 758fc6e..2b13c13 100644
--- a/src/lib/contracts/api.ts
+++ b/src/lib/contracts/api.ts
@@ -3,7 +3,7 @@ import { z } from 'zod';
export const toneSchema = z.enum(['professional', 'friendly', 'enthusiastic']);
export const lengthSchema = z.enum(['concise', 'standard', 'detailed']);
export const analysisTypeSchema = z.enum(['overview', 'keywords', 'match', 'coverLetter']);
-export const resumeTemplateIdSchema = z.enum(['awesome-classic', 'deedy-modern', 'sb2nov-ats']);
+export const resumeTemplateIdSchema = z.enum(['awesome-classic', 'deedy-modern', 'sb2nov-ats', 'custom']);
const freeTextSchema = z
.string()
@@ -58,6 +58,10 @@ export const tailoredResumeRequestSchema = z.object({
jobDescription: z.string().trim().min(1, 'Job description is required').max(15000, 'Job description is too long (max 15,000 characters)'),
templateId: resumeTemplateIdSchema.default('awesome-classic'),
resumeName: optionalFreeTextSchema,
+ builderSlug: z.string().trim().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Invalid builder slug format').min(4).max(120).optional(),
+ sourceAnalysisId: z.string().trim().min(1).max(128).optional(),
+ customTemplateName: optionalFreeTextSchema,
+ customTemplateLatex: z.string().trim().min(1).max(180000).optional(),
forceRegenerate: z.boolean().optional(),
idempotencyKey: z.string().trim().min(8).max(128).optional(),
});
@@ -76,12 +80,15 @@ export const apiErrorSchema = z.object({
export const historyItemSchema = z.object({
id: z.string(),
- type: z.enum(['analysis', 'cover-letter']),
+ type: z.enum(['analysis', 'cover-letter', 'resume']),
analysisType: z.string().optional(),
companyName: z.string().optional(),
resumeName: z.string().optional(),
jobTitle: z.string().optional(),
jobDescription: z.string().optional(),
+ templateId: z.string().optional(),
+ builderSlug: z.string().optional(),
+ version: z.number().optional(),
createdAt: z.string(),
result: z.string(),
});
@@ -118,6 +125,8 @@ export const tailoredResumeResponseSchema = z.object({
cached: z.boolean(),
source: z.enum(['database']).optional(),
documentId: z.string().optional(),
+ builderSlug: z.string().optional(),
+ version: z.number().optional(),
requestId: z.string(),
});
diff --git a/src/lib/convex-server.ts b/src/lib/convex-server.ts
index f296637..b895c3e 100644
--- a/src/lib/convex-server.ts
+++ b/src/lib/convex-server.ts
@@ -14,6 +14,7 @@ const convexFunctions = {
getUserCoverLetters: "functions:getUserCoverLetters",
getUserResumes: "functions:getUserResumes",
getUserTailoredResumes: "functions:getUserTailoredResumes",
+ getTailoredResumeVersionsBySlug: "functions:getTailoredResumeVersionsBySlug",
getUserStats: "functions:getUserStats",
saveAnalysis: "functions:saveAnalysis",
saveCoverLetter: "functions:saveCoverLetter",
@@ -86,12 +87,15 @@ export interface Resume {
export interface SearchHistoryItem {
id: string;
- type: "analysis" | "cover-letter";
+ type: "analysis" | "cover-letter" | "resume";
analysisType?: string;
companyName?: string;
resumeName?: string;
jobTitle?: string;
jobDescription?: string;
+ templateId?: string;
+ builderSlug?: string;
+ version?: number;
createdAt: string;
result: string;
}
@@ -109,6 +113,11 @@ export interface TailoredResume {
jobDescription?: string;
structuredData: string;
latexSource: string;
+ builderSlug?: string;
+ version?: number;
+ sourceAnalysisId?: string;
+ customTemplateName?: string;
+ customTemplateSource?: string;
}
// Helper: Generate hash for caching
@@ -384,6 +393,25 @@ export async function getUserTailoredResumes(
}
}
+export async function getTailoredResumeVersionsBySlug(
+ userId: string,
+ builderSlug: string,
+ limit = 30,
+): Promise {
+ try {
+ const client = getClient();
+ const docs = await client.query(convexFunctions.getTailoredResumeVersionsBySlug, {
+ userId,
+ builderSlug,
+ limit,
+ });
+ return docs as unknown as TailoredResume[];
+ } catch (error) {
+ console.error("Error fetching tailored resume versions by slug:", error);
+ return [];
+ }
+}
+
// ─── Stats ───────────────────────────────────────────────────────────
export async function getUserStats(userId: string): Promise<{
diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts
index b8e682c..e4a5315 100644
--- a/src/lib/gemini.ts
+++ b/src/lib/gemini.ts
@@ -167,12 +167,12 @@ export interface TailoredResumeData {
keywordsUsed: string[];
}
-function createGeminiModel(analysisType: AnalysisType | 'tailoredResume') {
+function createGeminiModel(analysisType: AnalysisType | 'tailoredResume' | 'latexFix') {
const modelName = process.env.MODEL_NAME || 'gemini-2.5-flash';
return genAI.getGenerativeModel({
model: modelName,
generationConfig: {
- temperature: analysisType === 'coverLetter' ? 0.8 : 0.4,
+ temperature: analysisType === 'coverLetter' ? 0.8 : analysisType === 'latexFix' ? 0.2 : 0.4,
maxOutputTokens: 16384,
},
});
@@ -188,6 +188,16 @@ function stripMarkdownJsonFence(input: string): string {
return withoutStart.replace(/\s*```$/, '').trim();
}
+function stripMarkdownCodeFence(input: string): string {
+ const trimmed = input.trim();
+ if (!trimmed.startsWith('```')) {
+ return trimmed;
+ }
+
+ const withoutStart = trimmed.replace(/^```(?:latex|tex|text)?\s*/i, '');
+ return withoutStart.replace(/\s*```$/, '').trim();
+}
+
export async function analyzeResume(
resumeText: string,
jobDescription: string,
@@ -339,3 +349,41 @@ ${jobDescription}`;
throw new Error('AI returned invalid structured resume data. Please try again.');
}
}
+
+export async function fixLatexCompilationError(
+ latexSource: string,
+ compileLog?: string,
+): Promise {
+ if (!apiKey) {
+ throw new Error('Google Gemini API key is not configured. Please set GOOGLE_API_KEY environment variable.');
+ }
+
+ const model = createGeminiModel('latexFix');
+ const boundedLog = (compileLog || '').slice(0, 12000);
+
+ const prompt = `You are a strict LaTeX repair assistant.
+Fix the provided .tex source so it compiles with pdflatex.
+
+Rules:
+1) Return ONLY the full corrected LaTeX source.
+2) Do not wrap output in markdown fences.
+3) Preserve document content and structure as much as possible.
+4) Apply minimal safe fixes for compilation errors (escape special chars like &, %, _, # when needed).
+5) Keep class/packages unless they directly break compile.
+
+Compiler log:
+${boundedLog || '(no compiler log provided)'}
+
+Original LaTeX:
+${latexSource}`;
+
+ const result = await model.generateContent(prompt);
+ const text = result.response.text();
+ const normalized = stripMarkdownCodeFence(text);
+
+ if (!normalized || normalized.trim().length === 0) {
+ throw new Error('AI returned an empty LaTeX fix response.');
+ }
+
+ return normalized;
+}
diff --git a/src/lib/latex-render.ts b/src/lib/latex-render.ts
new file mode 100644
index 0000000..51ad175
--- /dev/null
+++ b/src/lib/latex-render.ts
@@ -0,0 +1,67 @@
+const DEFAULT_RENDER_API_BASE = 'https://latexonline.cc';
+const URL_MODE_MAX_SOURCE_LENGTH = 6000;
+
+export function getLatexRenderApiBase(): string {
+ return (process.env.LATEX_RENDER_API_BASE || DEFAULT_RENDER_API_BASE).replace(/\/+$/, '');
+}
+
+export function buildLatexCompileUrl(latexSource: string): string {
+ const base = getLatexRenderApiBase();
+ return `${base}/compile?text=${encodeURIComponent(latexSource)}`;
+}
+
+function octalField(value: number, width: number): string {
+ const octal = value.toString(8);
+ return octal.padStart(width - 1, '0') + '\0';
+}
+
+// Build a minimal POSIX tar archive with a single file.
+function createSingleFileTar(filename: string, content: Buffer): Buffer {
+ const header = Buffer.alloc(512, 0);
+ header.write(filename.slice(0, 100), 0, 'utf8');
+ header.write(octalField(0o644, 8), 100, 'ascii');
+ header.write(octalField(0, 8), 108, 'ascii');
+ header.write(octalField(0, 8), 116, 'ascii');
+ header.write(octalField(content.length, 12), 124, 'ascii');
+ header.write(octalField(Math.floor(Date.now() / 1000), 12), 136, 'ascii');
+ header.fill(0x20, 148, 156);
+ header.write('0', 156, 'ascii');
+ header.write('ustar\0', 257, 'ascii');
+ header.write('00', 263, 'ascii');
+
+ let checksum = 0;
+ for (let i = 0; i < 512; i += 1) {
+ checksum += header[i];
+ }
+ const checksumText = checksum.toString(8).padStart(6, '0');
+ header.write(checksumText, 148, 'ascii');
+ header[154] = 0;
+ header[155] = 0x20;
+
+ const paddedContentLength = Math.ceil(content.length / 512) * 512;
+ const paddedContent = Buffer.alloc(paddedContentLength, 0);
+ content.copy(paddedContent, 0);
+
+ return Buffer.concat([header, paddedContent, Buffer.alloc(1024, 0)]);
+}
+
+export function shouldUseLatexUploadMode(latexSource: string): boolean {
+ return latexSource.length > URL_MODE_MAX_SOURCE_LENGTH;
+}
+
+export async function compileLatexViaUpload(latexSource: string): Promise {
+ const base = getLatexRenderApiBase();
+ const tarBuffer = createSingleFileTar('main.tex', Buffer.from(latexSource, 'utf8'));
+ const tarArrayBuffer = tarBuffer.buffer.slice(
+ tarBuffer.byteOffset,
+ tarBuffer.byteOffset + tarBuffer.byteLength,
+ ) as ArrayBuffer;
+ const formData = new FormData();
+ formData.append('file', new Blob([tarArrayBuffer], { type: 'application/x-tar' }), 'main.tar');
+
+ return fetch(`${base}/data?target=main.tex`, {
+ method: 'POST',
+ body: formData,
+ cache: 'no-store',
+ });
+}
diff --git a/src/lib/resume-latex.ts b/src/lib/resume-latex.ts
index 925f91a..4282ec2 100644
--- a/src/lib/resume-latex.ts
+++ b/src/lib/resume-latex.ts
@@ -1,11 +1,12 @@
import type { TailoredResumeData, TailoredResumeSectionItem } from '@/lib/gemini';
-export const RESUME_TEMPLATE_IDS = ['awesome-classic', 'deedy-modern', 'sb2nov-ats'] as const;
+export const BUILT_IN_RESUME_TEMPLATE_IDS = ['awesome-classic', 'deedy-modern', 'sb2nov-ats'] as const;
-export type ResumeTemplateId = (typeof RESUME_TEMPLATE_IDS)[number];
+export type BuiltInResumeTemplateId = (typeof BUILT_IN_RESUME_TEMPLATE_IDS)[number];
+export type ResumeTemplateId = BuiltInResumeTemplateId | 'custom';
export interface ResumeTemplateOption {
- id: ResumeTemplateId;
+ id: BuiltInResumeTemplateId;
name: string;
description: string;
atsFriendly: boolean;
@@ -52,8 +53,20 @@ function cleanSectionItems(items: TailoredResumeSectionItem[] | undefined, maxIt
.slice(0, maxItems);
}
-export function escapeLatex(input: string): string {
+function normalizeLatexText(input: string): string {
return input
+ .replace(/\r\n?/g, '\n')
+ .replace(/\u00A0/g, ' ')
+ .replace(/[‘’]/g, '\'')
+ .replace(/[“”]/g, '"')
+ .replace(/[–—]/g, '-')
+ .replace(/•/g, '-')
+ .replace(/…/g, '...')
+ .replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '');
+}
+
+export function escapeLatex(input: string): string {
+ return normalizeLatexText(input)
.replace(/\\/g, '\\textbackslash{}')
.replace(/&/g, '\\&')
.replace(/%/g, '\\%')
@@ -135,7 +148,7 @@ function renderBody(data: TailoredResumeData): string {
return sections.join('\n\n');
}
-function buildTemplatePreamble(templateId: ResumeTemplateId): string {
+function buildTemplatePreamble(templateId: BuiltInResumeTemplateId): string {
if (templateId === 'deedy-modern') {
return `\\documentclass[11pt]{article}
\\usepackage[margin=0.65in]{geometry}
@@ -175,7 +188,7 @@ function buildTemplatePreamble(templateId: ResumeTemplateId): string {
\\begin{document}`;
}
-export function buildLatexResume(templateId: ResumeTemplateId, rawData: TailoredResumeData): string {
+export function buildLatexResume(templateId: BuiltInResumeTemplateId, rawData: TailoredResumeData): string {
const data: TailoredResumeData = {
fullName: rawData.fullName?.trim(),
email: rawData.email?.trim(),
@@ -214,3 +227,95 @@ ${body}
\\end{document}
`;
}
+
+function toJsonString(value: unknown): string {
+ return JSON.stringify(value, null, 2);
+}
+
+export function buildLatexResumeFromCustomTemplate(templateSource: string, rawData: TailoredResumeData): string {
+ const builtInFallback = buildLatexResume('awesome-classic', rawData);
+ const experience = cleanSectionItems(rawData.experience, 6);
+ const projects = cleanSectionItems(rawData.projects, 6);
+ const education = cleanSectionItems(rawData.education, 4);
+ const skills = cleanList(rawData.skills, 30);
+ const certifications = cleanList(rawData.certifications, 15);
+ const additional = cleanList(rawData.additional, 15);
+
+ const replacements: Record = {
+ '{{fullName}}': escapeLatex(rawData.fullName?.trim() || ''),
+ '{{email}}': escapeLatex(rawData.email?.trim() || ''),
+ '{{phone}}': escapeLatex(rawData.phone?.trim() || ''),
+ '{{location}}': escapeLatex(rawData.location?.trim() || ''),
+ '{{linkedin}}': escapeLatex(rawData.linkedin?.trim() || ''),
+ '{{github}}': escapeLatex(rawData.github?.trim() || ''),
+ '{{website}}': escapeLatex(rawData.website?.trim() || ''),
+ '{{summary}}': escapeLatex(rawData.summary?.trim() || ''),
+ '{{targetTitle}}': escapeLatex(rawData.targetTitle?.trim() || ''),
+ '{{skills}}': escapeLatex(skills.join(', ')),
+ '{{skills_latex}}': renderSkills(skills),
+ '{{experience_entries}}': experience.map(renderEntry).join('\n\n'),
+ '{{projects_entries}}': projects.map(renderEntry).join('\n\n'),
+ '{{education_entries}}': education.map(renderEntry).join('\n\n'),
+ '{{certifications}}': certifications.map((item) => `\\textbullet{} ${escapeLatex(item)}`).join('\\\\\n'),
+ '{{additional}}': additional.map((item) => `\\textbullet{} ${escapeLatex(item)}`).join('\\\\\n'),
+ '{{keywordsUsed}}': escapeLatex((rawData.keywordsUsed ?? []).join(', ')),
+ '{{structuredDataJson}}': escapeLatex(toJsonString(rawData)),
+ '{{generated_resume}}': builtInFallback,
+ };
+
+ let output = templateSource;
+ for (const [placeholder, value] of Object.entries(replacements)) {
+ output = output.split(placeholder).join(value);
+ }
+ return output;
+}
+
+export const TEMPLATE_PREVIEW_DATA: TailoredResumeData = {
+ fullName: 'Jordan Rivera',
+ email: 'jordan.rivera@example.com',
+ phone: '+1 (555) 010-2193',
+ location: 'San Francisco, CA',
+ linkedin: 'linkedin.com/in/jordanrivera',
+ github: 'github.com/jordanrivera',
+ website: 'jordanrivera.dev',
+ summary: 'Product-minded software engineer with 6+ years building scalable web platforms, AI-assisted workflows, and data-heavy applications.',
+ skills: ['TypeScript', 'Next.js', 'Node.js', 'PostgreSQL', 'Redis', 'Docker', 'GraphQL', 'CI/CD'],
+ experience: [
+ {
+ title: 'Senior Software Engineer',
+ subtitle: 'BlueWave Systems',
+ date: '2022-Present',
+ location: 'Remote',
+ bullets: [
+ 'Led migration to Next.js App Router and reduced page load times by 34%.',
+ 'Built AI-assisted resume and cover letter workflows used by 50k+ users.',
+ 'Introduced observability dashboards that cut incident resolution time in half.',
+ ],
+ },
+ ],
+ projects: [
+ {
+ title: 'Hiring Intelligence Platform',
+ subtitle: 'Next.js, Convex, Gemini API',
+ date: '2024',
+ location: 'Remote',
+ bullets: [
+ 'Designed role-matching pipeline to score resumes against job descriptions.',
+ 'Implemented robust caching and idempotency for high-volume generation APIs.',
+ ],
+ },
+ ],
+ education: [
+ {
+ title: 'B.S. Computer Science',
+ subtitle: 'University of California, Davis',
+ date: '2017-2021',
+ location: 'Davis, CA',
+ bullets: [],
+ },
+ ],
+ certifications: ['AWS Certified Developer - Associate'],
+ additional: ['Speaker: Bay Area JS Meetup (2025)'],
+ targetTitle: 'Senior Full Stack Engineer',
+ keywordsUsed: ['scalable systems', 'AI workflows', 'cloud deployment'],
+};
diff --git a/src/types/domain.ts b/src/types/domain.ts
index 9cb47a1..61c8139 100644
--- a/src/types/domain.ts
+++ b/src/types/domain.ts
@@ -1,7 +1,7 @@
export type Tone = 'professional' | 'friendly' | 'enthusiastic';
export type LetterLength = 'concise' | 'standard' | 'detailed';
export type AnalysisType = 'overview' | 'keywords' | 'match' | 'coverLetter';
-export type HistoryType = 'analysis' | 'cover-letter';
+export type HistoryType = 'analysis' | 'cover-letter' | 'resume';
export interface ResumeItem {
_id: string;
@@ -48,4 +48,18 @@ export interface HistoryCoverLetterItem {
result: string;
}
-export type HistoryItem = HistoryAnalysisItem | HistoryCoverLetterItem;
+export interface HistoryResumeItem {
+ id: string;
+ type: 'resume';
+ resumeName?: string;
+ jobTitle?: string;
+ companyName?: string;
+ jobDescription?: string;
+ templateId?: string;
+ builderSlug?: string;
+ version?: number;
+ createdAt: string;
+ result: string;
+}
+
+export type HistoryItem = HistoryAnalysisItem | HistoryCoverLetterItem | HistoryResumeItem;
diff --git a/test/app/api/render-latex/route.test.ts b/test/app/api/render-latex/route.test.ts
new file mode 100644
index 0000000..62a561e
--- /dev/null
+++ b/test/app/api/render-latex/route.test.ts
@@ -0,0 +1,66 @@
+import { NextRequest } from 'next/server';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { POST } from '@/app/api/render-latex/route';
+import { getAuthenticatedUser } from '@/lib/auth';
+
+vi.mock('@/lib/auth', () => ({
+ getAuthenticatedUser: vi.fn(),
+}));
+
+describe('/api/render-latex', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getAuthenticatedUser).mockResolvedValue('u1');
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('returns 400 for invalid payload', async () => {
+ const request = new NextRequest('http://localhost/api/render-latex', {
+ method: 'POST',
+ body: JSON.stringify({ latexSource: '' }),
+ });
+
+ const response = await POST(request);
+ expect(response.status).toBe(400);
+ });
+
+ it('returns compiled pdf', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(new Uint8Array([1, 2, 3]), {
+ status: 200,
+ headers: { 'content-type': 'application/pdf' },
+ })));
+
+ const request = new NextRequest('http://localhost/api/render-latex', {
+ method: 'POST',
+ body: JSON.stringify({ latexSource: '\\documentclass{article}\\begin{document}Hello\\end{document}' }),
+ });
+
+ const response = await POST(request);
+ const buffer = await response.arrayBuffer();
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get('content-type')).toBe('application/pdf');
+ expect(buffer.byteLength).toBeGreaterThan(0);
+ });
+
+ it('returns compile error on upstream failure', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('bad latex', {
+ status: 400,
+ })));
+
+ const request = new NextRequest('http://localhost/api/render-latex', {
+ method: 'POST',
+ body: JSON.stringify({ latexSource: '\\badcommand' }),
+ });
+
+ const response = await POST(request);
+ const data = await response.json();
+
+ expect(response.status).toBe(422);
+ expect(data.code).toBe('LATEX_COMPILE_FAILED');
+ });
+});
From 67b9f3ff669047b0c8648a0eb3085051454c033a Mon Sep 17 00:00:00 2001
From: Aditya Mer
Date: Sun, 22 Feb 2026 15:28:59 +0530
Subject: [PATCH 3/7] feat(dashboard): add multi-step resume builder pages and
history integration
---
src/app/dashboard/history/page.tsx | 50 +-
.../dashboard/resume-builder/[slug]/page.tsx | 506 ++++++++++++++++++
src/app/dashboard/resume-builder/new/page.tsx | 246 +++++++++
src/app/dashboard/resume-builder/page.tsx | 418 +++++++++------
.../dashboard/resume-builder/step-2/page.tsx | 460 ++++++++++++++++
src/app/layout.tsx | 2 +-
.../dashboard/history/HistoryFilterTabs.tsx | 1 +
7 files changed, 1508 insertions(+), 175 deletions(-)
create mode 100644 src/app/dashboard/resume-builder/[slug]/page.tsx
create mode 100644 src/app/dashboard/resume-builder/new/page.tsx
create mode 100644 src/app/dashboard/resume-builder/step-2/page.tsx
diff --git a/src/app/dashboard/history/page.tsx b/src/app/dashboard/history/page.tsx
index e6f479e..afc1fe8 100644
--- a/src/app/dashboard/history/page.tsx
+++ b/src/app/dashboard/history/page.tsx
@@ -17,7 +17,7 @@ import { useState } from 'react'
import { HistoryFilterTabs } from '@/components/dashboard/history/HistoryFilterTabs'
import { Button } from '@/components/ui/button'
import { useHistory } from '@/hooks/useHistory'
-import type { HistoryAnalysisItem, HistoryType } from '@/types/domain'
+import type { HistoryAnalysisItem, HistoryItem, HistoryType } from '@/types/domain'
export default function HistoryPage() {
const { isLoading, isLoadingMore, error, hasMore, loadMore, filterItems, refresh } = useHistory(20)
@@ -70,6 +70,10 @@ export default function HistoryPage() {
}
const getPreview = (result: string): string => {
+ if (result.trim().startsWith('\\documentclass')) {
+ return 'Generated LaTeX resume source'
+ }
+
try {
const parsed = JSON.parse(result)
if (parsed.overview) return parsed.overview
@@ -84,6 +88,18 @@ export default function HistoryPage() {
setExpandedId(expandedId === id ? null : id)
}
+ const getDestinationHref = (item: HistoryItem): string => {
+ if (item.type === 'analysis') {
+ return `/dashboard/analysis/${item.id}`
+ }
+
+ if (item.type === 'cover-letter') {
+ return `/dashboard/cover-letter/${item.id}`
+ }
+
+ return item.builderSlug ? `/dashboard/resume-builder/${item.builderSlug}` : '/dashboard/resume-builder'
+ }
+
if (isLoading) {
return (
@@ -105,7 +121,7 @@ export default function HistoryPage() {
History
-
Your past analyses and generated cover letters.
+
Your past analyses, generated cover letters, and resumes.
@@ -135,7 +151,9 @@ export default function HistoryPage() {
? 'Run your first resume analysis to see it here.'
: filter === 'cover-letter'
? 'Generate your first cover letter to see it here.'
- : 'Start by running a resume analysis or generating a cover letter.'}
+ : filter === 'resume'
+ ? 'Build your first tailored resume to see it here.'
+ : 'Start by running a resume analysis, generating a cover letter, or building a resume.'}
@@ -161,20 +179,24 @@ export default function HistoryPage() {
toggleExpand(item.id)} className="w-full flex items-center gap-4 p-5 text-left">
- {item.type === 'analysis' ? : }
+ {item.type === 'analysis' ? : item.type === 'resume' ? : }
- {item.type === 'analysis' ? getAnalysisLabel((item as HistoryAnalysisItem).analysisType) : 'Cover Letter'}
+ {item.type === 'analysis' ? getAnalysisLabel((item as HistoryAnalysisItem).analysisType) : item.type === 'resume' ? 'Resume' : 'Cover Letter'}
{score !== null && (
@@ -201,6 +223,12 @@ export default function HistoryPage() {
: item.resumeName
? `Analysis for ${item.resumeName}`
: 'Resume Analysis'
+ : item.type === 'resume'
+ ? item.jobTitle && item.companyName
+ ? `Resume — ${item.jobTitle} at ${item.companyName}`
+ : item.resumeName
+ ? `Resume — ${item.resumeName}`
+ : 'Tailored Resume'
: item.companyName
? `Cover Letter — ${item.companyName}`
: item.resumeName
@@ -208,7 +236,7 @@ export default function HistoryPage() {
: 'Cover Letter'}
- {item.type === 'analysis' && (
+ {(item.type === 'analysis' || item.type === 'resume') && (
{item.resumeName || 'Resume'}
@@ -230,7 +258,7 @@ export default function HistoryPage() {
- {item.type === 'analysis' ? 'Analysis Result' : 'Generated Letter'}
+ {item.type === 'analysis' ? 'Analysis Result' : item.type === 'resume' ? 'Generated Resume' : 'Generated Letter'}
@@ -249,9 +277,9 @@ export default function HistoryPage() {
minute: '2-digit',
})}
-
+
- {item.type === 'analysis' ? 'Open Analysis' : 'Open Letter'}
+ {item.type === 'analysis' ? 'Open Analysis' : item.type === 'resume' ? 'Open Resume' : 'Open Letter'}
diff --git a/src/app/dashboard/resume-builder/[slug]/page.tsx b/src/app/dashboard/resume-builder/[slug]/page.tsx
new file mode 100644
index 0000000..2cbe7e4
--- /dev/null
+++ b/src/app/dashboard/resume-builder/[slug]/page.tsx
@@ -0,0 +1,506 @@
+'use client'
+
+import {
+ AlertCircle,
+ ChevronDown,
+ ChevronUp,
+ Copy,
+ Download,
+ Eye,
+ FileCode2,
+ History,
+ Loader2,
+ RefreshCw,
+ Save,
+ Sparkles,
+} from 'lucide-react'
+import Link from 'next/link'
+import { useParams } from 'next/navigation'
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+import { Button } from '@/components/ui/button'
+import { Textarea } from '@/components/ui/textarea'
+
+interface ResumeVersion {
+ id: string
+ version: number
+ latexSource: string
+ templateId: string
+ resumeName?: string
+ jobDescription?: string
+ sourceAnalysisId?: string
+ customTemplateName?: string
+ createdAt: string
+}
+
+interface SessionResponse {
+ slug: string
+ latestVersion: number
+ versions: ResumeVersion[]
+ requestId: string
+}
+
+function downloadText(content: string, fileName: string) {
+ const blob = new Blob([content], { type: 'application/x-tex;charset=utf-8' })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = fileName
+ document.body.appendChild(link)
+ link.click()
+ link.remove()
+ URL.revokeObjectURL(url)
+}
+
+function downloadBlob(blob: Blob, fileName: string) {
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = fileName
+ document.body.appendChild(link)
+ link.click()
+ link.remove()
+ URL.revokeObjectURL(url)
+}
+
+export default function ResumeBuilderSlugPage() {
+ const { slug } = useParams<{ slug: string }>()
+
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ const [versions, setVersions] = useState([])
+ const [activeVersionId, setActiveVersionId] = useState(null)
+ const [historyOpen, setHistoryOpen] = useState(false)
+ const [fullViewerOpen, setFullViewerOpen] = useState(false)
+
+ const [latexSource, setLatexSource] = useState('')
+ const [isSavingVersion, setIsSavingVersion] = useState(false)
+
+ const [previewUrl, setPreviewUrl] = useState(null)
+ const [previewBlob, setPreviewBlob] = useState(null)
+ const [isRendering, setIsRendering] = useState(false)
+ const [renderError, setRenderError] = useState(null)
+ const [lastCompileLog, setLastCompileLog] = useState(null)
+ const [isAiFixing, setIsAiFixing] = useState(false)
+
+ const [copied, setCopied] = useState(false)
+
+ const debounceRef = useRef(null)
+ const objectUrlsRef = useRef>(new Set())
+
+ const trackUrl = useCallback((url: string) => {
+ objectUrlsRef.current.add(url)
+ return url
+ }, [])
+
+ const revokeUrl = useCallback((url?: string | null) => {
+ if (!url) return
+ if (objectUrlsRef.current.has(url)) {
+ URL.revokeObjectURL(url)
+ objectUrlsRef.current.delete(url)
+ }
+ }, [])
+
+ const renderLatex = useCallback(async (source: string) => {
+ setIsRendering(true)
+ setRenderError(null)
+
+ try {
+ const response = await fetch('/api/render-latex', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ latexSource: source }),
+ })
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}))
+ const compileLog = typeof data?.details?.log === 'string' ? data.details.log : ''
+ setLastCompileLog(compileLog || null)
+ const logLine = compileLog.split('\n').find((line: string) => line.trim().length > 0)
+ const reason = logLine ? ` ${logLine.slice(0, 220)}` : ''
+ throw new Error(`${data.message || data.error || 'Failed to render LaTeX'}${reason}`)
+ }
+
+ const blob = await response.blob()
+ setLastCompileLog(null)
+ setPreviewBlob(blob)
+ const url = trackUrl(URL.createObjectURL(blob))
+ setPreviewUrl((current) => {
+ revokeUrl(current)
+ return url
+ })
+ } catch (err) {
+ setRenderError(err instanceof Error ? err.message : 'Failed to render PDF preview')
+ } finally {
+ setIsRendering(false)
+ }
+ }, [revokeUrl, trackUrl])
+
+ useEffect(() => {
+ if (!slug) {
+ return
+ }
+
+ async function loadSession() {
+ try {
+ const response = await fetch(`/api/resume-builder/${slug}`)
+ if (!response.ok) {
+ if (response.status === 404) {
+ setError('Resume builder session not found')
+ return
+ }
+ if (response.status === 403) {
+ setError('You do not have access to this session')
+ return
+ }
+ if (response.status === 401) {
+ setError('Please sign in to view this session')
+ return
+ }
+ throw new Error('Failed to load session')
+ }
+
+ const data = await response.json() as SessionResponse
+ setVersions(data.versions)
+
+ const latest = data.versions[0]
+ if (latest) {
+ setActiveVersionId(latest.id)
+ setLatexSource(latest.latexSource)
+ await renderLatex(latest.latexSource)
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load session')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ void loadSession()
+ }, [renderLatex, slug])
+
+ useEffect(() => {
+ if (!latexSource.trim()) {
+ return
+ }
+
+ if (debounceRef.current) {
+ window.clearTimeout(debounceRef.current)
+ }
+
+ debounceRef.current = window.setTimeout(() => {
+ void renderLatex(latexSource)
+ }, 650)
+
+ return () => {
+ if (debounceRef.current) {
+ window.clearTimeout(debounceRef.current)
+ }
+ }
+ }, [latexSource, renderLatex])
+
+ useEffect(() => {
+ const trackedUrls = objectUrlsRef.current
+ return () => {
+ if (debounceRef.current) {
+ window.clearTimeout(debounceRef.current)
+ }
+
+ for (const url of trackedUrls) {
+ URL.revokeObjectURL(url)
+ }
+ trackedUrls.clear()
+ }
+ }, [])
+
+ const saveVersion = async () => {
+ if (!slug || !latexSource.trim()) {
+ return
+ }
+
+ setIsSavingVersion(true)
+ try {
+ const response = await fetch(`/api/resume-builder/${slug}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ latexSource }),
+ })
+
+ const data = await response.json()
+ if (!response.ok) {
+ throw new Error(data.message || data.error || 'Failed to save version')
+ }
+
+ const createdVersion: ResumeVersion = {
+ id: data.id,
+ version: data.version,
+ latexSource,
+ templateId: versions[0]?.templateId || 'custom',
+ resumeName: versions[0]?.resumeName,
+ jobDescription: versions[0]?.jobDescription,
+ sourceAnalysisId: versions[0]?.sourceAnalysisId,
+ customTemplateName: versions[0]?.customTemplateName,
+ createdAt: data.createdAt,
+ }
+
+ setVersions((current) => [createdVersion, ...current])
+ setActiveVersionId(createdVersion.id)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save version')
+ } finally {
+ setIsSavingVersion(false)
+ }
+ }
+
+ const handleCopyLatex = async () => {
+ await navigator.clipboard.writeText(latexSource)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1500)
+ }
+
+ const handleAiFixLatex = async () => {
+ if (!latexSource.trim()) {
+ return
+ }
+
+ setIsAiFixing(true)
+ try {
+ const response = await fetch('/api/fix-latex', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ latexSource,
+ compileLog: lastCompileLog || renderError || undefined,
+ }),
+ })
+
+ const data = await response.json()
+ if (!response.ok) {
+ throw new Error(data.message || data.error || 'Failed to apply AI LaTeX fix')
+ }
+
+ const fixedLatex = typeof data.fixedLatex === 'string' ? data.fixedLatex : ''
+ if (!fixedLatex.trim()) {
+ throw new Error('AI did not return any fixed LaTeX source')
+ }
+
+ setLatexSource(fixedLatex)
+ setRenderError(null)
+ setLastCompileLog(null)
+ await renderLatex(fixedLatex)
+ } catch (err) {
+ setRenderError(err instanceof Error ? err.message : 'Failed to auto-fix LaTeX')
+ } finally {
+ setIsAiFixing(false)
+ }
+ }
+
+ const activeVersion = versions.find((item) => item.id === activeVersionId) || versions[0] || null
+ const previewEmbedSrc = previewUrl ? `${previewUrl}#page=1&view=FitH&zoom=page-fit&navpanes=0&toolbar=0&scrollbar=0` : null
+
+ if (isLoading) {
+ return (
+
+
+
+
Loading resume builder session...
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
+
Unable to open session
+
{error}
+
+ Back to Builder
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+ Resume Builder Session
+
+
/dashboard/resume-builder/{slug}
+ {activeVersion && (
+
+ Version {activeVersion.version} • {new Date(activeVersion.createdAt).toLocaleString('en-US')}
+
+ )}
+
+
+
+
+
+ setHistoryOpen((current) => !current)}
+ className="flex w-full items-center justify-between rounded-xl border border-border/70 bg-background/70 px-4 py-3 text-left"
+ >
+
+
+ Version History ({versions.length})
+
+ {historyOpen ? : }
+
+
+ {historyOpen && (
+
+ {versions.map((version) => (
+
{
+ setActiveVersionId(version.id)
+ setLatexSource(version.latexSource)
+ void renderLatex(version.latexSource)
+ }}
+ className={`rounded-lg border p-3 text-left text-xs ${activeVersionId === version.id ? 'border-primary bg-primary/10' : 'border-border/70 bg-background/70'}`}
+ >
+ Version {version.version}
+ {new Date(version.createdAt).toLocaleString('en-US')}
+
+ ))}
+
+ )}
+
+
+
+
+
+
LaTeX Editor
+
+ void handleAiFixLatex()}
+ disabled={isAiFixing || isRendering || !latexSource.trim()}
+ >
+ {isAiFixing ? : }
+ AI Fix
+
+
+
+ {copied ? 'Copied' : 'Copy .tex'}
+
+ downloadText(latexSource, `${slug}.tex`)}>
+
+ Download .tex
+
+ void saveVersion()} disabled={isSavingVersion || !latexSource.trim()}>
+ {isSavingVersion ? : }
+ Save Version
+
+
+
+
setLatexSource(event.target.value)}
+ className="min-h-[640px] rounded-xl border-border/80 bg-background/90 font-mono text-xs leading-relaxed"
+ />
+
+
+
+
+
Rendered PDF Preview
+
+ {isRendering && (
+
+
+ Rendering...
+
+ )}
+ void renderLatex(latexSource)} disabled={isRendering}>
+
+ Re-render
+
+ {
+ if (previewBlob) {
+ downloadBlob(previewBlob, `${slug}.pdf`)
+ }
+ }}
+ >
+
+ Download PDF
+
+ setFullViewerOpen(true)}
+ >
+
+ Full Viewer
+
+
+
+
+ {renderError ? (
+
+ {renderError}
+
+ ) : previewUrl ? (
+
+
+
+ ) : (
+
+
+ Preview will appear after rendering.
+
+ )}
+
+
+
+
+ {fullViewerOpen && previewUrl && (
+
+
+
+
Full PDF Viewer
+
+ window.open(previewUrl, '_blank', 'noopener,noreferrer')}
+ >
+ Open in New Tab
+
+ setFullViewerOpen(false)}>
+ Close
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/app/dashboard/resume-builder/new/page.tsx b/src/app/dashboard/resume-builder/new/page.tsx
new file mode 100644
index 0000000..06d33b0
--- /dev/null
+++ b/src/app/dashboard/resume-builder/new/page.tsx
@@ -0,0 +1,246 @@
+'use client'
+
+import {
+ AlertCircle,
+ ArrowLeft,
+ Loader2,
+ Sparkles,
+} from 'lucide-react'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { useEffect, useRef, useState } from 'react'
+
+import { GenerationProgress } from '@/components/dashboard/GenerationProgress'
+import { useGenerationFlow } from '@/hooks/useGenerationFlow'
+import type { ResumeTemplateId } from '@/lib/resume-latex'
+
+type ResumeBuilderSourceDraft =
+ | {
+ kind: 'manual'
+ resumeText: string
+ resumeName: string
+ jobDescription: string
+ }
+ | {
+ kind: 'analysis'
+ analysisId: string
+ resumeName: string
+ jobDescription: string
+ jobTitle?: string
+ companyName?: string
+ }
+
+interface ResumeBuilderDraft {
+ source: ResumeBuilderSourceDraft
+ template?: {
+ templateId: ResumeTemplateId
+ customTemplateName?: string
+ customTemplateLatex?: string
+ }
+}
+
+interface TailoredResumeResponse {
+ templateId: ResumeTemplateId
+ documentId?: string
+ builderSlug?: string
+ version?: number
+ requestId: string
+}
+
+interface SavedResumeRecord {
+ name?: string
+ textContent?: string
+}
+
+const RESUME_BUILDER_DRAFT_KEY = 'resumeBuilderFlowDraftV1'
+
+const LOADING_STEPS = [
+ 'Reading job description and constraints...',
+ 'Extracting relevant profile signals...',
+ 'Tailoring content to role keywords...',
+ 'Formatting LaTeX with selected template...',
+ 'Compiling validation preview...',
+ 'Saving private builder session...',
+]
+
+function readDraft(): ResumeBuilderDraft | null {
+ if (typeof window === 'undefined') {
+ return null
+ }
+
+ const raw = window.sessionStorage.getItem(RESUME_BUILDER_DRAFT_KEY)
+ if (!raw) {
+ return null
+ }
+
+ try {
+ return JSON.parse(raw) as ResumeBuilderDraft
+ } catch {
+ window.sessionStorage.removeItem(RESUME_BUILDER_DRAFT_KEY)
+ return null
+ }
+}
+
+function createSlug(input: string) {
+ const cleaned = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-')
+ const base = cleaned.length > 0 ? cleaned : 'resume-builder'
+ return `${base}-${Math.random().toString(36).slice(2, 9)}`
+}
+
+export default function ResumeBuilderNewPage() {
+ const router = useRouter()
+ const startedRef = useRef(false)
+
+ const [error, setError] = useState(null)
+
+ const {
+ isGenerating,
+ loadingStep,
+ estimatedSecondsRemaining,
+ runGeneration,
+ cancelGeneration,
+ } = useGenerationFlow(LOADING_STEPS, { estimatedTotalSeconds: 22 })
+
+ useEffect(() => {
+ if (startedRef.current) {
+ return
+ }
+ startedRef.current = true
+
+ const draft = readDraft()
+ if (!draft?.source || !draft.template) {
+ setError('Missing Step 1/Step 2 data. Start again from resume builder.')
+ return
+ }
+
+ const run = async () => {
+ try {
+ const source = draft.source
+ const template = draft.template
+ if (!template) {
+ throw new Error('Template is missing. Return to Step 2.')
+ }
+
+ let resolvedResumeText = source.kind === 'manual' ? source.resumeText : ''
+ if (!resolvedResumeText && source.kind === 'analysis') {
+ const resumesRes = await fetch('/api/resumes?limit=100')
+ if (!resumesRes.ok) {
+ throw new Error('Failed to load resumes for selected analysis')
+ }
+
+ const resumesPayload = await resumesRes.json()
+ const resumes: SavedResumeRecord[] = Array.isArray(resumesPayload.resumes) ? resumesPayload.resumes : []
+ const matched = resumes.find((item) => item.name === source.resumeName)
+
+ if (!matched?.textContent) {
+ throw new Error(`Source resume "${source.resumeName}" not found in saved resumes`)
+ }
+
+ resolvedResumeText = matched.textContent
+ }
+
+ if (!resolvedResumeText) {
+ throw new Error('Missing resume text for generation')
+ }
+
+ const slugSeed = source.kind === 'analysis'
+ ? (source.resumeName || source.jobTitle || 'resume-builder')
+ : (source.resumeName || 'resume-builder')
+ const builderSlug = createSlug(slugSeed)
+
+ const response = await runGeneration((signal) => fetch('/api/generate-resume-latex', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ resumeText: resolvedResumeText,
+ resumeName: source.resumeName,
+ jobDescription: source.jobDescription,
+ templateId: template.templateId,
+ builderSlug,
+ sourceAnalysisId: source.kind === 'analysis' ? source.analysisId : undefined,
+ customTemplateName: template.templateId === 'custom' ? template.customTemplateName : undefined,
+ customTemplateLatex: template.templateId === 'custom' ? template.customTemplateLatex : undefined,
+ idempotencyKey: crypto.randomUUID(),
+ }),
+ signal,
+ }))
+
+ const data = await response.json() as TailoredResumeResponse & { message?: string; error?: string }
+ if (!response.ok) {
+ throw new Error(data.message || data.error || 'Failed to build resume')
+ }
+
+ router.replace(`/dashboard/resume-builder/${data.builderSlug || builderSlug}`)
+ } catch (err) {
+ if (err instanceof Error && err.message.includes('canceled')) {
+ setError('Resume generation canceled')
+ return
+ }
+
+ setError(err instanceof Error ? err.message : 'Failed to build resume')
+ }
+ }
+
+ void run()
+ }, [router, runGeneration])
+
+ if (error) {
+ return (
+
+
+
+
Unable to build resume
+
{error}
+
+
+
+ Back to Step 2
+
+
+
+
+ Restart Flow
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+ {isGenerating ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ Cancel
+
+
+
+
+ )
+}
diff --git a/src/app/dashboard/resume-builder/page.tsx b/src/app/dashboard/resume-builder/page.tsx
index b84f8b3..188f8f9 100644
--- a/src/app/dashboard/resume-builder/page.tsx
+++ b/src/app/dashboard/resume-builder/page.tsx
@@ -1,101 +1,192 @@
'use client'
-import { AlertCircle, CheckCircle, Copy, Download, FileCode2, FileText, Sparkles } from 'lucide-react'
-import { useState } from 'react'
+import { useUser } from '@stackframe/stack'
+import {
+ AlertCircle,
+ CheckCircle,
+ Clock,
+ FileCode2,
+ Sparkles,
+} from 'lucide-react'
+import { useRouter } from 'next/navigation'
+import { useEffect, useState } from 'react'
import { ResumeSelect } from '@/components/resume/ResumeSelect'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
-import { RESUME_TEMPLATE_OPTIONS, type ResumeTemplateId } from '@/lib/resume-latex'
-
-interface TailoredResumeResponse {
- latexSource: string
- structuredData: Record
- templateId: ResumeTemplateId
- cached: boolean
- source?: 'database'
- documentId?: string
- requestId: string
+import { useResumes } from '@/hooks/useResumes'
+
+type HistoryType = 'analysis' | 'cover-letter'
+type InputMode = 'manual' | 'analysis'
+
+const RESUME_BUILDER_DRAFT_KEY = 'resumeBuilderFlowDraftV1'
+
+interface SearchHistoryItem {
+ id: string
+ type: HistoryType
+ analysisType?: string
+ companyName?: string
+ resumeName?: string
+ jobTitle?: string
+ jobDescription?: string
+ createdAt: string
}
-function downloadLatex(content: string, fileName: string) {
- const blob = new Blob([content], { type: 'application/x-tex;charset=utf-8' })
- const url = URL.createObjectURL(blob)
- const link = document.createElement('a')
- link.href = url
- link.download = fileName
- document.body.appendChild(link)
- link.click()
- link.remove()
- URL.revokeObjectURL(url)
+type RecentAnalysis = SearchHistoryItem & { type: 'analysis' }
+
+type ResumeBuilderSourceDraft =
+ | {
+ kind: 'manual'
+ resumeText: string
+ resumeName: string
+ jobDescription: string
+ }
+ | {
+ kind: 'analysis'
+ analysisId: string
+ resumeName: string
+ jobDescription: string
+ jobTitle?: string
+ companyName?: string
+ }
+
+interface ResumeBuilderDraft {
+ source: ResumeBuilderSourceDraft
+ template?: {
+ templateId: 'awesome-classic' | 'deedy-modern' | 'sb2nov-ats' | 'custom'
+ customTemplateName?: string
+ customTemplateLatex?: string
+ }
}
-function safeFileName(input: string) {
- const normalized = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-')
- return normalized.length > 0 ? normalized.slice(0, 80) : 'tailored-resume'
+function readDraft(): ResumeBuilderDraft | null {
+ if (typeof window === 'undefined') {
+ return null
+ }
+
+ const raw = window.sessionStorage.getItem(RESUME_BUILDER_DRAFT_KEY)
+ if (!raw) {
+ return null
+ }
+
+ try {
+ return JSON.parse(raw) as ResumeBuilderDraft
+ } catch {
+ window.sessionStorage.removeItem(RESUME_BUILDER_DRAFT_KEY)
+ return null
+ }
}
-export default function ResumeBuilderPage() {
+function writeDraft(draft: ResumeBuilderDraft) {
+ if (typeof window === 'undefined') {
+ return
+ }
+ window.sessionStorage.setItem(RESUME_BUILDER_DRAFT_KEY, JSON.stringify(draft))
+}
+
+export default function ResumeBuilderStep1Page() {
+ const user = useUser()
+ const router = useRouter()
+ const { resumes, isLoading: resumesLoading } = useResumes(100)
+
+ const [inputMode, setInputMode] = useState('manual')
const [jobDescription, setJobDescription] = useState('')
const [resumeText, setResumeText] = useState(null)
const [resumeName, setResumeName] = useState(null)
- const [templateId, setTemplateId] = useState('awesome-classic')
- const [isGenerating, setIsGenerating] = useState(false)
+
+ const [isInitializing, setIsInitializing] = useState(true)
+ const [recentAnalyses, setRecentAnalyses] = useState([])
const [error, setError] = useState(null)
- const [result, setResult] = useState(null)
- const [copied, setCopied] = useState(false)
- const handleGenerate = async () => {
- if (!jobDescription.trim()) {
- setError('Please enter a job description')
- return
+ useEffect(() => {
+ const existing = readDraft()
+ if (existing?.source?.kind === 'manual') {
+ setInputMode('manual')
+ setResumeName(existing.source.resumeName)
+ setResumeText(existing.source.resumeText)
+ setJobDescription(existing.source.jobDescription)
+ }
+ }, [])
+
+ useEffect(() => {
+ async function loadRecentAnalyses() {
+ if (!user?.id) {
+ setIsInitializing(false)
+ return
+ }
+
+ try {
+ const response = await fetch('/api/search-history?limit=20')
+ if (!response.ok) {
+ return
+ }
+
+ const data = await response.json()
+ const allHistory: SearchHistoryItem[] = Array.isArray(data.history) ? data.history : []
+ const analyses = allHistory
+ .filter((item): item is RecentAnalysis => item.type === 'analysis' && item.analysisType === 'match')
+ .slice(0, 8)
+
+ setRecentAnalyses(analyses)
+ } catch (loadError) {
+ console.error('Failed to load recent analyses', loadError)
+ } finally {
+ setIsInitializing(false)
+ }
}
- if (!resumeText) {
+
+ void loadRecentAnalyses()
+ }, [user?.id])
+
+ const handleContinueManual = () => {
+ if (!resumeText || !resumeName) {
setError('Please select a resume first')
return
}
- setIsGenerating(true)
+ if (!jobDescription.trim()) {
+ setError('Please enter a job description')
+ return
+ }
+
+ writeDraft({
+ source: {
+ kind: 'manual',
+ resumeText,
+ resumeName,
+ jobDescription: jobDescription.trim(),
+ },
+ })
+
setError(null)
+ router.push('/dashboard/resume-builder/step-2')
+ }
- try {
- const response = await fetch('/api/generate-resume-latex', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- resumeText,
- jobDescription: jobDescription.trim(),
- templateId,
- resumeName: resumeName || undefined,
- idempotencyKey: crypto.randomUUID(),
- }),
- })
-
- const data = await response.json()
- if (!response.ok) {
- throw new Error(data.message || data.error || 'Failed to generate LaTeX resume')
- }
+ const handleUseAnalysis = (analysis: RecentAnalysis) => {
+ if (!analysis.jobDescription || !analysis.resumeName) {
+ setError('This analysis is missing source resume or job description')
+ return
+ }
- setResult(data as TailoredResumeResponse)
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to generate resume')
- setResult(null)
- } finally {
- setIsGenerating(false)
+ const matchedResume = resumes.find((item) => item.name === analysis.resumeName)
+ if (!matchedResume) {
+ setError(`Source resume "${analysis.resumeName}" is not available in your saved resumes`)
+ return
}
- }
- const handleCopy = async () => {
- if (!result?.latexSource) return
- await navigator.clipboard.writeText(result.latexSource)
- setCopied(true)
- setTimeout(() => setCopied(false), 1500)
- }
+ writeDraft({
+ source: {
+ kind: 'analysis',
+ analysisId: analysis.id,
+ resumeName: analysis.resumeName,
+ jobDescription: analysis.jobDescription,
+ jobTitle: analysis.jobTitle,
+ companyName: analysis.companyName,
+ },
+ })
- const handleDownload = () => {
- if (!result?.latexSource) return
- const fileBase = safeFileName(resumeName || 'tailored-resume')
- downloadLatex(result.latexSource, `${fileBase}-${templateId}.tex`)
+ setError(null)
+ router.push('/dashboard/resume-builder/step-2')
}
return (
@@ -109,66 +200,97 @@ export default function ResumeBuilderPage() {
- Resume Builder
+ Resume Builder • Step 1/3
- Generate tailored LaTeX resume
+ Choose your source
- Pick a resume template, paste the job description, and generate role-tailored LaTeX source powered by Gemini.
+ Start with current resume + job description, or select an existing match analysis and reuse its exact resume/job description.
-
-
-
-
-
-
Job description
-
Paste complete role requirements for best tailoring.
-
+
+ setInputMode('manual')}
+ className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold ${inputMode === 'manual' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground'}`}
+ >
+ Manual Input
+
+ setInputMode('analysis')}
+ className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold ${inputMode === 'analysis' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground'}`}
+ >
+ Use Previous Analysis
+
-
setJobDescription(e.target.value)}
- placeholder="Paste the full job description here..."
- className="min-h-[330px] resize-none rounded-2xl border-border/80 bg-background/80 text-foreground placeholder:text-muted-foreground focus-visible:ring-primary/45"
- />
+ {inputMode === 'manual' ? (
+
+
+
Current resume
+ {
+ setResumeText(text)
+ setResumeName(name)
+ }}
+ selectedName={resumeName ?? undefined}
+ />
+
-
-
- {isGenerating ? (
- <>
-
- Generating...
- >
- ) : (
- <>
-
- Generate LaTeX Resume
- >
- )}
-
+
+
Job description
+ setJobDescription(event.target.value)}
+ placeholder="Paste the full job description here..."
+ className="min-h-[280px] resize-none rounded-2xl border-border/80 bg-background/80 text-foreground placeholder:text-muted-foreground focus-visible:ring-primary/45"
+ />
+
- {result?.latexSource && (
- <>
-
-
- {copied ? 'Copied' : 'Copy .tex'}
-
-
-
- Download .tex
+
+
+ Continue to Step 2
+
- >
- )}
-
+
+
+ ) : (
+
+ {isInitializing || resumesLoading ? (
+
Loading previous analyses...
+ ) : recentAnalyses.length === 0 ? (
+
No recent match analyses available.
+ ) : (
+ recentAnalyses.map((analysis) => {
+ const resumeExists = resumes.some((item) => item.name === analysis.resumeName)
+ return (
+
+
{analysis.jobTitle || 'Saved analysis'}
+
Resume: {analysis.resumeName || 'Unknown'}
+
+ {analysis.companyName || 'Unknown company'} • {new Date(analysis.createdAt).toLocaleDateString('en-US')}
+
+
+ {resumeExists ? 'Ready: source resume found' : 'Missing source resume in saved resumes'}
+
+
handleUseAnalysis(analysis)}
+ disabled={!resumeExists}
+ >
+ Use This Analysis
+
+
+ )
+ })
+ )}
+
+ )}
{error && (
@@ -178,62 +300,32 @@ export default function ResumeBuilderPage() {
)}
-
- {result?.latexSource && (
-
-
-
Generated LaTeX
-
- {result.cached ? `Loaded from ${result.source || 'cache'}` : 'Freshly generated'}
-
-
-
-
- )}
-
-
-
Select resume
+
Flow
+
+
1. Choose source
+
2. Pick template / upload .tex
+
3. Build and open editor
-
{
- setResumeText(text)
- setResumeName(name)
- }} selectedName={resumeName ?? undefined} />
- Choose template
-
- {RESUME_TEMPLATE_OPTIONS.map((template) => (
-
setTemplateId(template.id)}
- className={`w-full rounded-xl border p-3 text-left transition-colors ${templateId === template.id ? 'border-primary bg-primary/10' : 'border-border/70 bg-background/70 hover:border-border'}`}
- >
- {template.name}
- {template.description}
-
- ))}
+
+
+
Tips
-
-
-
- Notes
- Gemini is instructed not to invent facts that are missing from your original resume.
- Output is `.tex` source now, so you can compile with your preferred LaTeX toolchain.
- Try multiple templates and compare ATS readability and visual style.
+ Use full job description text for better keyword alignment.
+ If reusing analysis, resume + JD are locked to that analysis source.
+ You can edit LaTeX and save multiple versions later.
+
+
+ Private session per authenticated user
+
diff --git a/src/app/dashboard/resume-builder/step-2/page.tsx b/src/app/dashboard/resume-builder/step-2/page.tsx
new file mode 100644
index 0000000..35a2372
--- /dev/null
+++ b/src/app/dashboard/resume-builder/step-2/page.tsx
@@ -0,0 +1,460 @@
+'use client'
+
+import {
+ AlertCircle,
+ Check,
+ ChevronLeft,
+ Eye,
+ FileCode2,
+ Loader2,
+ Upload,
+} from 'lucide-react'
+import { useRouter } from 'next/navigation'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+
+import { Button } from '@/components/ui/button'
+import {
+ buildLatexResume,
+ buildLatexResumeFromCustomTemplate,
+ type BuiltInResumeTemplateId,
+ RESUME_TEMPLATE_OPTIONS,
+ type ResumeTemplateId,
+ TEMPLATE_PREVIEW_DATA,
+} from '@/lib/resume-latex'
+
+type ResumeBuilderSourceDraft =
+ | {
+ kind: 'manual'
+ resumeText: string
+ resumeName: string
+ jobDescription: string
+ }
+ | {
+ kind: 'analysis'
+ analysisId: string
+ resumeName: string
+ jobDescription: string
+ jobTitle?: string
+ companyName?: string
+ }
+
+interface ResumeBuilderDraft {
+ source: ResumeBuilderSourceDraft
+ template?: {
+ templateId: ResumeTemplateId
+ customTemplateName?: string
+ customTemplateLatex?: string
+ }
+}
+
+const RESUME_BUILDER_DRAFT_KEY = 'resumeBuilderFlowDraftV1'
+
+function readDraft(): ResumeBuilderDraft | null {
+ if (typeof window === 'undefined') {
+ return null
+ }
+
+ const raw = window.sessionStorage.getItem(RESUME_BUILDER_DRAFT_KEY)
+ if (!raw) {
+ return null
+ }
+
+ try {
+ return JSON.parse(raw) as ResumeBuilderDraft
+ } catch {
+ window.sessionStorage.removeItem(RESUME_BUILDER_DRAFT_KEY)
+ return null
+ }
+}
+
+function writeDraft(draft: ResumeBuilderDraft) {
+ if (typeof window === 'undefined') {
+ return
+ }
+ window.sessionStorage.setItem(RESUME_BUILDER_DRAFT_KEY, JSON.stringify(draft))
+}
+
+export default function ResumeBuilderStep2Page() {
+ const router = useRouter()
+
+ const [isBooting, setIsBooting] = useState(true)
+ const [draft, setDraft] = useState(null)
+
+ const [templateId, setTemplateId] = useState('awesome-classic')
+ const [customTemplateName, setCustomTemplateName] = useState(null)
+ const [customTemplateLatex, setCustomTemplateLatex] = useState(null)
+
+ const [templatePreviewUrls, setTemplatePreviewUrls] = useState>>({})
+ const [templatePreviewLoading, setTemplatePreviewLoading] = useState>>({})
+ const [customPreviewUrl, setCustomPreviewUrl] = useState(null)
+ const [customPreviewLoading, setCustomPreviewLoading] = useState(false)
+ const [viewerUrl, setViewerUrl] = useState(null)
+ const viewerEmbedSrc = viewerUrl ? `${viewerUrl}#page=1&view=FitH&zoom=page-fit&navpanes=0&toolbar=0&scrollbar=0` : null
+
+ const [error, setError] = useState(null)
+
+ const objectUrlsRef = useRef>(new Set())
+
+ const trackUrl = useCallback((url: string) => {
+ objectUrlsRef.current.add(url)
+ return url
+ }, [])
+
+ const revokeUrl = useCallback((url?: string | null) => {
+ if (!url) return
+ if (objectUrlsRef.current.has(url)) {
+ URL.revokeObjectURL(url)
+ objectUrlsRef.current.delete(url)
+ }
+ }, [])
+
+ const renderLatexToPdfUrl = useCallback(async (latexSource: string): Promise => {
+ const response = await fetch('/api/render-latex', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ latexSource }),
+ })
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}))
+ const compileLog = typeof data?.details?.log === 'string' ? data.details.log : ''
+ const logLine = compileLog.split('\n').find((line: string) => line.trim().length > 0)
+ const reason = logLine ? ` ${logLine.slice(0, 180)}` : ''
+ throw new Error(`${data.message || data.error || 'Failed to render preview'}${reason}`)
+ }
+
+ const blob = await response.blob()
+ return trackUrl(URL.createObjectURL(blob))
+ }, [trackUrl])
+
+ useEffect(() => {
+ const loaded = readDraft()
+ if (!loaded?.source) {
+ router.replace('/dashboard/resume-builder')
+ return
+ }
+
+ setDraft(loaded)
+ if (loaded.template) {
+ setTemplateId(loaded.template.templateId)
+ setCustomTemplateName(loaded.template.customTemplateName || null)
+ setCustomTemplateLatex(loaded.template.customTemplateLatex || null)
+ }
+
+ setIsBooting(false)
+ }, [router])
+
+ useEffect(() => {
+ let disposed = false
+
+ async function loadTemplatePreviews() {
+ const templateIds = RESUME_TEMPLATE_OPTIONS.map((template) => template.id)
+ setTemplatePreviewLoading(Object.fromEntries(templateIds.map((id) => [id, true])) as Partial>)
+
+ await Promise.all(templateIds.map(async (id) => {
+ try {
+ const latex = buildLatexResume(id, TEMPLATE_PREVIEW_DATA)
+ const previewUrl = await renderLatexToPdfUrl(latex)
+ if (disposed) {
+ revokeUrl(previewUrl)
+ return
+ }
+
+ setTemplatePreviewUrls((current) => {
+ const previous = current[id]
+ if (previous) {
+ revokeUrl(previous)
+ }
+ return { ...current, [id]: previewUrl }
+ })
+ } catch {
+ // noop
+ } finally {
+ if (!disposed) {
+ setTemplatePreviewLoading((current) => ({ ...current, [id]: false }))
+ }
+ }
+ }))
+ }
+
+ void loadTemplatePreviews()
+
+ return () => {
+ disposed = true
+ }
+ }, [renderLatexToPdfUrl, revokeUrl])
+
+ useEffect(() => {
+ let disposed = false
+
+ async function loadCustomPreview() {
+ if (!customTemplateLatex) {
+ setCustomPreviewUrl((current) => {
+ revokeUrl(current)
+ return null
+ })
+ return
+ }
+
+ setCustomPreviewLoading(true)
+ try {
+ const latex = buildLatexResumeFromCustomTemplate(customTemplateLatex, TEMPLATE_PREVIEW_DATA)
+ const url = await renderLatexToPdfUrl(latex)
+ if (disposed) {
+ revokeUrl(url)
+ return
+ }
+
+ setCustomPreviewUrl((current) => {
+ revokeUrl(current)
+ return url
+ })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to render custom template preview')
+ } finally {
+ if (!disposed) {
+ setCustomPreviewLoading(false)
+ }
+ }
+ }
+
+ void loadCustomPreview()
+
+ return () => {
+ disposed = true
+ }
+ }, [customTemplateLatex, renderLatexToPdfUrl, revokeUrl])
+
+ useEffect(() => {
+ const tracked = objectUrlsRef.current
+ return () => {
+ for (const url of tracked) {
+ URL.revokeObjectURL(url)
+ }
+ tracked.clear()
+ }
+ }, [])
+
+ const selectedTemplateName = useMemo(() => {
+ if (templateId === 'custom') {
+ return customTemplateName || 'Custom Template'
+ }
+
+ return RESUME_TEMPLATE_OPTIONS.find((template) => template.id === templateId)?.name || 'Template'
+ }, [customTemplateName, templateId])
+
+ const handleCustomTemplateUpload = async (file: File) => {
+ if (!file.name.toLowerCase().endsWith('.tex')) {
+ setError('Please upload a .tex file')
+ return
+ }
+
+ const content = await file.text()
+ setCustomTemplateName(file.name)
+ setCustomTemplateLatex(content)
+ setTemplateId('custom')
+ setError(null)
+ }
+
+ const continueToBuild = () => {
+ if (!draft) {
+ return
+ }
+
+ if (templateId === 'custom' && !customTemplateLatex) {
+ setError('Upload a custom .tex template before continuing')
+ return
+ }
+
+ writeDraft({
+ ...draft,
+ template: {
+ templateId,
+ customTemplateName: templateId === 'custom' ? customTemplateName || undefined : undefined,
+ customTemplateLatex: templateId === 'custom' ? customTemplateLatex || undefined : undefined,
+ },
+ })
+
+ setError(null)
+ router.push('/dashboard/resume-builder/new')
+ }
+
+ if (isBooting) {
+ return (
+
+
+
+ Loading step 2...
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+ Resume Builder • Step 2/3
+
+ Choose and preview template
+
+ Pick a built-in template or upload custom `.tex`. Every template can be opened in a larger live preview before generation.
+
+
+
+
+
+
router.push('/dashboard/resume-builder')}>
+
+ Back to Step 1
+
+
+
+
Selected: {selectedTemplateName}
+
+
+
+
+ {RESUME_TEMPLATE_OPTIONS.map((template) => (
+
+
+
+
{template.name}
+
{template.description}
+
+ {templateId === template.id &&
}
+
+
+ {templatePreviewLoading[template.id] ? (
+
+
+ Rendering preview...
+
+ ) : templatePreviewUrls[template.id] ? (
+
+ ) : (
+
+ Preview unavailable
+
+ )}
+
+
+ setTemplateId(template.id)}>
+ {templateId === template.id ? 'Selected' : 'Select'}
+
+ setViewerUrl(templatePreviewUrls[template.id] || null)}
+ disabled={!templatePreviewUrls[template.id]}
+ >
+
+ Open
+
+
+
+ ))}
+
+
+
+
+
+
Custom `.tex` template
+
Upload your own LaTeX template and preview it with sample resume data.
+
+ {templateId === 'custom' &&
}
+
+
+
+
+ {customTemplateName ? `Replace ${customTemplateName}` : 'Upload .tex file'}
+ {
+ const file = event.target.files?.[0]
+ if (file) {
+ void handleCustomTemplateUpload(file)
+ }
+ }}
+ />
+
+
+ {customTemplateName && (
+ Loaded: {customTemplateName}
+ )}
+
+
+ setTemplateId('custom')} disabled={!customTemplateLatex}>
+ {templateId === 'custom' ? 'Selected' : 'Select Custom'}
+
+ setViewerUrl(customPreviewUrl)}
+ disabled={!customPreviewUrl || customPreviewLoading}
+ >
+ {customPreviewLoading ? : }
+ Open Preview
+
+
+
+
+ {error && (
+
+ )}
+
+
+
+ Generate JD Tailored Resume
+
+
+
+
+
+ {viewerUrl && (
+
+
+
+
Template Preview
+
+ window.open(viewerUrl, '_blank', 'noopener,noreferrer')}
+ >
+ Open in New Tab
+
+ setViewerUrl(null)}>
+ Close
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 6fa2387..19d4335 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -39,7 +39,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
+
diff --git a/src/components/dashboard/history/HistoryFilterTabs.tsx b/src/components/dashboard/history/HistoryFilterTabs.tsx
index 34ce0dd..a0938c1 100644
--- a/src/components/dashboard/history/HistoryFilterTabs.tsx
+++ b/src/components/dashboard/history/HistoryFilterTabs.tsx
@@ -12,6 +12,7 @@ export function HistoryFilterTabs({ filter, onChange }: HistoryFilterTabsProps)
{ key: 'all' as const, label: 'All' },
{ key: 'analysis' as const, label: 'Analyses' },
{ key: 'cover-letter' as const, label: 'Cover Letters' },
+ { key: 'resume' as const, label: 'Resumes' },
].map((tab) => (
Date: Sun, 22 Feb 2026 15:29:03 +0530
Subject: [PATCH 4/7] test/docs: align retry-flow specs and update setup notes
---
.env.example | 1 +
README.md | 3 ++-
test/app/dashboard/analysis/[slug]/page.test.tsx | 7 +------
test/app/dashboard/cover-letter/[slug]/page.test.tsx | 7 +------
4 files changed, 5 insertions(+), 13 deletions(-)
diff --git a/.env.example b/.env.example
index e4f6659..bb389e3 100644
--- a/.env.example
+++ b/.env.example
@@ -6,6 +6,7 @@ AI_TIMEOUT_MS="30000"
PDF_PARSE_TIMEOUT_MS="12000"
COVER_LETTER_ROUTE_TIMEOUT_MS="35000"
RESUME_ROUTE_TIMEOUT_MS="45000"
+LATEX_RENDER_API_BASE="https://latexonline.cc"
# Convex Variables
NEXT_PUBLIC_CONVEX_URL=""
diff --git a/README.md b/README.md
index fb5b747..1eccc4c 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,7 @@ AI_TIMEOUT_MS="30000"
PDF_PARSE_TIMEOUT_MS="12000"
COVER_LETTER_ROUTE_TIMEOUT_MS="35000"
RESUME_ROUTE_TIMEOUT_MS="45000"
+LATEX_RENDER_API_BASE="https://latexonline.cc"
UPSTASH_REDIS_REST_URL=""
UPSTASH_REDIS_REST_TOKEN=""
# Only set true for local emergency fallback; keep false/empty in production
@@ -83,4 +84,4 @@ bunx convex dev
bun run dev
```
-App runs at `http://localhost:3000`.
\ No newline at end of file
+App runs at `http://localhost:3000`.
diff --git a/test/app/dashboard/analysis/[slug]/page.test.tsx b/test/app/dashboard/analysis/[slug]/page.test.tsx
index d894557..db7924b 100644
--- a/test/app/dashboard/analysis/[slug]/page.test.tsx
+++ b/test/app/dashboard/analysis/[slug]/page.test.tsx
@@ -1,5 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -81,7 +80,7 @@ describe('/dashboard/analysis/[slug] flow', () => {
expect(await screen.findByText('Generation failed')).toBeInTheDocument();
});
- it('retries generation after failure when user clicks retry', async () => {
+ it('retries generation automatically after a transient failure', async () => {
sessionStorage.setItem('pendingAnalysisGeneration', JSON.stringify({
resumeText: 'Resume text',
jobDescription: 'Job description',
@@ -89,7 +88,6 @@ describe('/dashboard/analysis/[slug] flow', () => {
}));
mockFetch
- .mockResolvedValueOnce(createResponse(false, 500, { error: 'Generation failed' }))
.mockResolvedValueOnce(createResponse(false, 500, { error: 'Generation failed' }))
.mockResolvedValueOnce(createResponse(true, 200, {
documentId: 'analysis-retry-1',
@@ -99,9 +97,6 @@ describe('/dashboard/analysis/[slug] flow', () => {
render( );
- const retryButton = await screen.findByRole('button', { name: /Retry Generation/i });
- await userEvent.click(retryButton);
-
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/dashboard/analysis/analysis-retry-1');
});
diff --git a/test/app/dashboard/cover-letter/[slug]/page.test.tsx b/test/app/dashboard/cover-letter/[slug]/page.test.tsx
index 1deaf92..4cea877 100644
--- a/test/app/dashboard/cover-letter/[slug]/page.test.tsx
+++ b/test/app/dashboard/cover-letter/[slug]/page.test.tsx
@@ -1,5 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -81,7 +80,7 @@ describe('/dashboard/cover-letter/[slug] flow', () => {
expect(await screen.findByText('Cover letter generation failed')).toBeInTheDocument();
});
- it('retries generation after failure when user clicks retry', async () => {
+ it('retries generation automatically after a transient failure', async () => {
sessionStorage.setItem('pendingCoverLetterGeneration', JSON.stringify({
resumeText: 'Resume text',
jobDescription: 'Job description',
@@ -90,7 +89,6 @@ describe('/dashboard/cover-letter/[slug] flow', () => {
}));
mockFetch
- .mockResolvedValueOnce(createResponse(false, 500, { error: 'Cover letter generation failed' }))
.mockResolvedValueOnce(createResponse(false, 500, { error: 'Cover letter generation failed' }))
.mockResolvedValueOnce(createResponse(true, 200, {
documentId: 'cover-retry-1',
@@ -99,9 +97,6 @@ describe('/dashboard/cover-letter/[slug] flow', () => {
render( );
- const retryButton = await screen.findByRole('button', { name: /Retry Generation/i });
- await userEvent.click(retryButton);
-
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/dashboard/cover-letter/cover-retry-1');
});
From 4a7a8a1b63253b1c6bcc4b4939d15122f96ab224 Mon Sep 17 00:00:00 2001
From: Aditya Mer
Date: Sun, 22 Feb 2026 16:11:55 +0530
Subject: [PATCH 5/7] feat(resume): update Jake's resume template with new
content and formatting
---
public/jake's_resume.tex | 218 +++++++++++++++++++++++++++++++++++++++
src/lib/resume-latex.ts | 159 ++++++++++++++++++++++++++--
2 files changed, 368 insertions(+), 9 deletions(-)
create mode 100644 public/jake's_resume.tex
diff --git a/public/jake's_resume.tex b/public/jake's_resume.tex
new file mode 100644
index 0000000..b4665ce
--- /dev/null
+++ b/public/jake's_resume.tex
@@ -0,0 +1,218 @@
+%-------------------------
+% Resume in Latex
+% Author : Jake Gutierrez
+% Based off of: https://github.com/sb2nov/resume
+% License : MIT
+%------------------------
+
+\documentclass[letterpaper,11pt]{article}
+
+\usepackage{latexsym}
+\usepackage[empty]{fullpage}
+\usepackage{titlesec}
+\usepackage{marvosym}
+\usepackage[usenames,dvipsnames]{color}
+\usepackage{verbatim}
+\usepackage{enumitem}
+\usepackage[hidelinks]{hyperref}
+\usepackage{fancyhdr}
+\usepackage[english]{babel}
+\usepackage{tabularx}
+\input{glyphtounicode}
+
+
+%----------FONT OPTIONS----------
+% sans-serif
+% \usepackage[sfdefault]{FiraSans}
+% \usepackage[sfdefault]{roboto}
+% \usepackage[sfdefault]{noto-sans}
+% \usepackage[default]{sourcesanspro}
+
+% serif
+% \usepackage{CormorantGaramond}
+% \usepackage{charter}
+
+
+\pagestyle{fancy}
+\fancyhf{} % clear all header and footer fields
+\fancyfoot{}
+\renewcommand{\headrulewidth}{0pt}
+\renewcommand{\footrulewidth}{0pt}
+
+% Adjust margins
+\addtolength{\oddsidemargin}{-0.5in}
+\addtolength{\evensidemargin}{-0.5in}
+\addtolength{\textwidth}{1in}
+\addtolength{\topmargin}{-.5in}
+\addtolength{\textheight}{1.0in}
+
+\urlstyle{same}
+
+\raggedbottom
+\raggedright
+\setlength{\tabcolsep}{0in}
+
+% Sections formatting
+\titleformat{\section}{
+ \vspace{-4pt}\scshape\raggedright\large
+}{}{0em}{}[\color{black}\titlerule \vspace{-5pt}]
+
+% Ensure that generate pdf is machine readable/ATS parsable
+\pdfgentounicode=1
+
+%-------------------------
+% Custom commands
+\newcommand{\resumeItem}[1]{
+ \item\small{
+ {#1 \vspace{-2pt}}
+ }
+}
+
+\newcommand{\resumeSubheading}[4]{
+ \vspace{-2pt}\item
+ \begin{tabular*}{0.97\textwidth}[t]{l@{\extracolsep{\fill}}r}
+ \textbf{#1} & #2 \\
+ \textit{\small#3} & \textit{\small #4} \\
+ \end{tabular*}\vspace{-7pt}
+}
+
+\newcommand{\resumeSubSubheading}[2]{
+ \item
+ \begin{tabular*}{0.97\textwidth}{l@{\extracolsep{\fill}}r}
+ \textit{\small#1} & \textit{\small #2} \\
+ \end{tabular*}\vspace{-7pt}
+}
+
+\newcommand{\resumeProjectHeading}[2]{
+ \item
+ \begin{tabular*}{0.97\textwidth}{l@{\extracolsep{\fill}}r}
+ \small#1 & #2 \\
+ \end{tabular*}\vspace{-7pt}
+}
+
+\newcommand{\resumeSubItem}[1]{\resumeItem{#1}\vspace{-4pt}}
+
+\renewcommand\labelitemii{$\vcenter{\hbox{\tiny$\bullet$}}$}
+
+\newcommand{\resumeSubHeadingListStart}{\begin{itemize}[leftmargin=0.15in, label={}]}
+\newcommand{\resumeSubHeadingListEnd}{\end{itemize}}
+\newcommand{\resumeItemListStart}{\begin{itemize}}
+\newcommand{\resumeItemListEnd}{\end{itemize}\vspace{-5pt}}
+
+%-------------------------------------------
+%%%%%% RESUME STARTS HERE %%%%%%%%%%%%%%%%%%%%%%%%%%%%
+
+
+\begin{document}
+
+%----------HEADING----------
+% \begin{tabular*}{\textwidth}{l@{\extracolsep{\fill}}r}
+% \textbf{\href{http://sourabhbajaj.com/}{\Large Sourabh Bajaj}} & Email : \href{mailto:sourabh@sourabhbajaj.com}{sourabh@sourabhbajaj.com}\\
+% \href{http://sourabhbajaj.com/}{http://www.sourabhbajaj.com} & Mobile : +1-123-456-7890 \\
+% \end{tabular*}
+
+\begin{center}
+ \textbf{\Huge \scshape Jake Ryan} \\ \vspace{1pt}
+ \small 123-456-7890 $|$ \href{mailto:x@x.com}{\underline{jake@su.edu}} $|$
+ \href{https://linkedin.com/in/...}{\underline{linkedin.com/in/jake}} $|$
+ \href{https://github.com/...}{\underline{github.com/jake}}
+\end{center}
+
+
+%-----------EDUCATION-----------
+\section{Education}
+ \resumeSubHeadingListStart
+ \resumeSubheading
+ {Southwestern University}{Georgetown, TX}
+ {Bachelor of Arts in Computer Science, Minor in Business}{Aug. 2018 -- May 2021}
+ \resumeSubheading
+ {Blinn College}{Bryan, TX}
+ {Associate's in Liberal Arts}{Aug. 2014 -- May 2018}
+ \resumeSubHeadingListEnd
+
+
+%-----------EXPERIENCE-----------
+\section{Experience}
+ \resumeSubHeadingListStart
+
+ \resumeSubheading
+ {Undergraduate Research Assistant}{June 2020 -- Present}
+ {Texas A\&M University}{College Station, TX}
+ \resumeItemListStart
+ \resumeItem{Developed a REST API using FastAPI and PostgreSQL to store data from learning management systems}
+ \resumeItem{Developed a full-stack web application using Flask, React, PostgreSQL and Docker to analyze GitHub data}
+ \resumeItem{Explored ways to visualize GitHub collaboration in a classroom setting}
+ \resumeItemListEnd
+
+% -----------Multiple Positions Heading-----------
+% \resumeSubSubheading
+% {Software Engineer I}{Oct 2014 - Sep 2016}
+% \resumeItemListStart
+% \resumeItem{Apache Beam}
+% {Apache Beam is a unified model for defining both batch and streaming data-parallel processing pipelines}
+% \resumeItemListEnd
+% \resumeSubHeadingListEnd
+%-------------------------------------------
+
+ \resumeSubheading
+ {Information Technology Support Specialist}{Sep. 2018 -- Present}
+ {Southwestern University}{Georgetown, TX}
+ \resumeItemListStart
+ \resumeItem{Communicate with managers to set up campus computers used on campus}
+ \resumeItem{Assess and troubleshoot computer problems brought by students, faculty and staff}
+ \resumeItem{Maintain upkeep of computers, classroom equipment, and 200 printers across campus}
+ \resumeItemListEnd
+
+ \resumeSubheading
+ {Artificial Intelligence Research Assistant}{May 2019 -- July 2019}
+ {Southwestern University}{Georgetown, TX}
+ \resumeItemListStart
+ \resumeItem{Explored methods to generate video game dungeons based off of \emph{The Legend of Zelda}}
+ \resumeItem{Developed a game in Java to test the generated dungeons}
+ \resumeItem{Contributed 50K+ lines of code to an established codebase via Git}
+ \resumeItem{Conducted a human subject study to determine which video game dungeon generation technique is enjoyable}
+ \resumeItem{Wrote an 8-page paper and gave multiple presentations on-campus}
+ \resumeItem{Presented virtually to the World Conference on Computational Intelligence}
+ \resumeItemListEnd
+
+ \resumeSubHeadingListEnd
+
+
+%-----------PROJECTS-----------
+\section{Projects}
+ \resumeSubHeadingListStart
+ \resumeProjectHeading
+ {\textbf{Gitlytics} $|$ \emph{Python, Flask, React, PostgreSQL, Docker}}{June 2020 -- Present}
+ \resumeItemListStart
+ \resumeItem{Developed a full-stack web application using with Flask serving a REST API with React as the frontend}
+ \resumeItem{Implemented GitHub OAuth to get data from user’s repositories}
+ \resumeItem{Visualized GitHub data to show collaboration}
+ \resumeItem{Used Celery and Redis for asynchronous tasks}
+ \resumeItemListEnd
+ \resumeProjectHeading
+ {\textbf{Simple Paintball} $|$ \emph{Spigot API, Java, Maven, TravisCI, Git}}{May 2018 -- May 2020}
+ \resumeItemListStart
+ \resumeItem{Developed a Minecraft server plugin to entertain kids during free time for a previous job}
+ \resumeItem{Published plugin to websites gaining 2K+ downloads and an average 4.5/5-star review}
+ \resumeItem{Implemented continuous delivery using TravisCI to build the plugin upon new a release}
+ \resumeItem{Collaborated with Minecraft server administrators to suggest features and get feedback about the plugin}
+ \resumeItemListEnd
+ \resumeSubHeadingListEnd
+
+
+
+%
+%-----------PROGRAMMING SKILLS-----------
+\section{Technical Skills}
+ \begin{itemize}[leftmargin=0.15in, label={}]
+ \small{\item{
+ \textbf{Languages}{: Java, Python, C/C++, SQL (Postgres), JavaScript, HTML/CSS, R} \\
+ \textbf{Frameworks}{: React, Node.js, Flask, JUnit, WordPress, Material-UI, FastAPI} \\
+ \textbf{Developer Tools}{: Git, Docker, TravisCI, Google Cloud Platform, VS Code, Visual Studio, PyCharm, IntelliJ, Eclipse} \\
+ \textbf{Libraries}{: pandas, NumPy, Matplotlib}
+ }}
+ \end{itemize}
+
+
+%-------------------------------------------
+\end{document}
diff --git a/src/lib/resume-latex.ts b/src/lib/resume-latex.ts
index 4282ec2..11bb059 100644
--- a/src/lib/resume-latex.ts
+++ b/src/lib/resume-latex.ts
@@ -15,8 +15,8 @@ export interface ResumeTemplateOption {
export const RESUME_TEMPLATE_OPTIONS: ResumeTemplateOption[] = [
{
id: 'awesome-classic',
- name: 'Awesome Classic',
- description: 'Two-line section headers with polished spacing and readable hierarchy.',
+ name: "Jake's Resume",
+ description: "Jake Gutierrez's classic one-page LaTeX resume template from Overleaf.",
atsFriendly: true,
},
{
@@ -102,6 +102,49 @@ function renderEntry(entry: TailoredResumeSectionItem): string {
return `${header}\n${bullets}`.trim();
}
+function renderJakeBullets(items: string[]): string {
+ if (items.length === 0) {
+ return '';
+ }
+
+ const rows = items.map((item) => ` \\resumeItem{${escapeLatex(item)}}`).join('\n');
+ return ` \\resumeItemListStart\n${rows}\n \\resumeItemListEnd`;
+}
+
+function renderJakeEntry(entry: TailoredResumeSectionItem): string {
+ const topRight = entry.date ? escapeLatex(entry.date) : '';
+ const bottomLeft = entry.subtitle ? escapeLatex(entry.subtitle) : '';
+ const bottomRight = entry.location ? escapeLatex(entry.location) : '';
+
+ const heading = ` \\resumeSubheading\n {${escapeLatex(entry.title)}}{${topRight}}\n {${bottomLeft}}{${bottomRight}}`;
+ const bullets = renderJakeBullets(entry.bullets);
+ return bullets ? `${heading}\n${bullets}` : heading;
+}
+
+function renderJakeEntrySection(title: string, entries: TailoredResumeSectionItem[]): string {
+ if (entries.length === 0) {
+ return '';
+ }
+
+ return `\\section{${escapeLatex(title)}}\n \\resumeSubHeadingListStart\n${entries.map(renderJakeEntry).join('\n\n')}\n \\resumeSubHeadingListEnd`;
+}
+
+function renderJakeProjectEntry(entry: TailoredResumeSectionItem): string {
+ const subtitle = entry.subtitle ? ` $|$ \\emph{${escapeLatex(entry.subtitle)}}` : '';
+ const date = entry.date ? escapeLatex(entry.date) : '';
+ const heading = ` \\resumeProjectHeading\n {\\textbf{${escapeLatex(entry.title)}}${subtitle}}{${date}}`;
+ const bullets = renderJakeBullets(entry.bullets);
+ return bullets ? `${heading}\n${bullets}` : heading;
+}
+
+function renderJakeProjectsSection(entries: TailoredResumeSectionItem[]): string {
+ if (entries.length === 0) {
+ return '';
+ }
+
+ return `\\section{Projects}\n \\resumeSubHeadingListStart\n${entries.map(renderJakeProjectEntry).join('\n\n')}\n \\resumeSubHeadingListEnd`;
+}
+
function renderSection(title: string, content: string): string {
if (!content.trim()) {
return '';
@@ -126,7 +169,44 @@ function renderContact(data: TailoredResumeData): string {
return parts.join(' \\textbar{} ');
}
-function renderBody(data: TailoredResumeData): string {
+function buildHref(value: string): string {
+ const trimmed = normalizeLatexText(value).trim();
+ if (!trimmed) {
+ return '';
+ }
+
+ if (/^(https?:\/\/|mailto:)/i.test(trimmed)) {
+ return trimmed;
+ }
+
+ return `https://${trimmed}`;
+}
+
+function renderJakeContact(data: TailoredResumeData): string {
+ const parts: string[] = [];
+
+ if (data.phone?.trim()) {
+ parts.push(escapeLatex(data.phone.trim()));
+ }
+
+ if (data.email?.trim()) {
+ const email = data.email.trim();
+ parts.push(`\\href{mailto:${email}}{\\underline{${escapeLatex(email)}}}`);
+ }
+
+ for (const value of [data.linkedin, data.github, data.website]) {
+ if (!value?.trim()) {
+ continue;
+ }
+
+ const trimmed = value.trim();
+ parts.push(`\\href{${buildHref(trimmed)}}{\\underline{${escapeLatex(trimmed)}}}`);
+ }
+
+ return parts.join(' $|$ ');
+}
+
+function renderBody(templateId: BuiltInResumeTemplateId, data: TailoredResumeData): string {
const summary = data.summary.trim();
const skills = cleanList(data.skills, 18);
const experience = cleanSectionItems(data.experience, 5);
@@ -135,6 +215,20 @@ function renderBody(data: TailoredResumeData): string {
const certifications = cleanList(data.certifications, 8);
const additional = cleanList(data.additional, 8);
+ if (templateId === 'awesome-classic') {
+ const skillsInline = skills.map((item) => escapeLatex(item)).join(', ');
+ const sections = [
+ renderJakeEntrySection('Education', education),
+ renderJakeEntrySection('Experience', experience),
+ renderJakeProjectsSection(projects),
+ skills.length > 0
+ ? `\\section{Technical Skills}\n \\begin{itemize}[leftmargin=0.15in, label={}]\n \\small{\\item{\\textbf{Skills}{: ${skillsInline}}}}\n \\end{itemize}`
+ : '',
+ ].filter(Boolean);
+
+ return sections.join('\n\n');
+ }
+
const sections = [
summary ? renderSection('Summary', escapeLatex(summary)) : '',
renderSection('Skills', renderSkills(skills)),
@@ -176,14 +270,47 @@ function buildTemplatePreamble(templateId: BuiltInResumeTemplateId): string {
\\begin{document}`;
}
- return `\\documentclass[11pt]{article}
-\\usepackage[margin=0.7in]{geometry}
+ return `\\documentclass[letterpaper,11pt]{article}
+\\usepackage{latexsym}
+\\usepackage[empty]{fullpage}
+\\usepackage{titlesec}
+\\usepackage{marvosym}
+\\usepackage[usenames,dvipsnames]{color}
+\\usepackage{verbatim}
\\usepackage{enumitem}
+\\usepackage[hidelinks]{hyperref}
+\\usepackage{fancyhdr}
+\\usepackage[english]{babel}
+\\usepackage{tabularx}
\\usepackage[T1]{fontenc}
\\usepackage[utf8]{inputenc}
-\\usepackage{lmodern}
-\\setlength{\\parindent}{0pt}
-\\setlength{\\parskip}{5pt}
+\\input{glyphtounicode}
+\\pagestyle{fancy}
+\\fancyhf{}
+\\fancyfoot{}
+\\renewcommand{\\headrulewidth}{0pt}
+\\renewcommand{\\footrulewidth}{0pt}
+\\addtolength{\\oddsidemargin}{-0.5in}
+\\addtolength{\\evensidemargin}{-0.5in}
+\\addtolength{\\textwidth}{1in}
+\\addtolength{\\topmargin}{-.5in}
+\\addtolength{\\textheight}{1.0in}
+\\urlstyle{same}
+\\raggedbottom
+\\raggedright
+\\setlength{\\tabcolsep}{0in}
+\\titleformat{\\section}{\\vspace{-4pt}\\scshape\\raggedright\\large}{}{0em}{}[\\color{black}\\titlerule \\vspace{-5pt}]
+\\pdfgentounicode=1
+\\newcommand{\\resumeItem}[1]{\\item\\small{{#1 \\vspace{-2pt}}}}
+\\newcommand{\\resumeSubheading}[4]{\\vspace{-2pt}\\item\\begin{tabular*}{0.97\\textwidth}[t]{l@{\\extracolsep{\\fill}}r}\\textbf{#1} & #2 \\\\\\textit{\\small#3} & \\textit{\\small #4} \\\\\\end{tabular*}\\vspace{-7pt}}
+\\newcommand{\\resumeSubSubheading}[2]{\\item\\begin{tabular*}{0.97\\textwidth}{l@{\\extracolsep{\\fill}}r}\\textit{\\small#1} & \\textit{\\small #2} \\\\\\end{tabular*}\\vspace{-7pt}}
+\\newcommand{\\resumeProjectHeading}[2]{\\item\\begin{tabular*}{0.97\\textwidth}{l@{\\extracolsep{\\fill}}r}\\small#1 & #2 \\\\\\end{tabular*}\\vspace{-7pt}}
+\\newcommand{\\resumeSubItem}[1]{\\resumeItem{#1}\\vspace{-4pt}}
+\\renewcommand\\labelitemii{$\\vcenter{\\hbox{\\tiny$\\bullet$}}$}
+\\newcommand{\\resumeSubHeadingListStart}{\\begin{itemize}[leftmargin=0.15in, label={}]}
+\\newcommand{\\resumeSubHeadingListEnd}{\\end{itemize}}
+\\newcommand{\\resumeItemListStart}{\\begin{itemize}}
+\\newcommand{\\resumeItemListEnd}{\\end{itemize}\\vspace{-5pt}}
\\pagenumbering{gobble}
\\begin{document}`;
}
@@ -211,10 +338,24 @@ export function buildLatexResume(templateId: BuiltInResumeTemplateId, rawData: T
const name = escapeLatex(data.fullName || 'Candidate Name');
const contact = renderContact(data);
const headline = data.targetTitle?.trim() ? `\\textit{${escapeLatex(data.targetTitle.trim())}}` : '';
- const body = renderBody(data);
+ const body = renderBody(templateId, data);
const preamble = buildTemplatePreamble(templateId);
+ if (templateId === 'awesome-classic') {
+ const contactParts = renderJakeContact(data);
+ return `${preamble}
+\\begin{center}
+ \\textbf{\\Huge \\scshape ${name}} \\\\ \\vspace{1pt}
+ ${contactParts ? `\\small ${contactParts}` : ''}
+\\end{center}
+
+${body}
+
+\\end{document}
+`;
+ }
+
return `${preamble}
\\begin{center}
{\\LARGE \\textbf{${name}}}\\\\
From cd82256d1a5d0c31a417beed370bf082a00f6dda Mon Sep 17 00:00:00 2001
From: Aditya Mer
Date: Sun, 22 Feb 2026 16:17:51 +0530
Subject: [PATCH 6/7] feat(tests): update latex resume test to include sections
for Experience and Technical Skills
---
test/lib/resume-latex.test.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/test/lib/resume-latex.test.ts b/test/lib/resume-latex.test.ts
index 5434156..7033cc7 100644
--- a/test/lib/resume-latex.test.ts
+++ b/test/lib/resume-latex.test.ts
@@ -40,7 +40,8 @@ describe('resume-latex', () => {
expect(output).toContain('\\documentclass');
expect(output).toContain('Ada Lovelace');
expect(output).toContain('Software Engineer');
- expect(output).toContain('\\section*{Summary}');
+ expect(output).toContain('\\section{Experience}');
+ expect(output).toContain('\\section{Technical Skills}');
expect(output).toContain('\\end{document}');
});
});
From 9d50a099bafaf91914357109de62adc75c494b93 Mon Sep 17 00:00:00 2001
From: Aditya Mer
Date: Sun, 22 Feb 2026 16:23:39 +0530
Subject: [PATCH 7/7] feat(resume): update resume template ID to 'jake-classic'
across multiple files
---
src/app/dashboard/resume-builder/page.tsx | 2 +-
src/app/dashboard/resume-builder/step-2/page.tsx | 2 +-
src/lib/contracts/api.ts | 4 ++--
src/lib/resume-latex.ts | 13 +++++++------
test/app/api/generate-resume-latex/route.test.ts | 4 ++--
test/lib/resume-latex.test.ts | 2 +-
6 files changed, 14 insertions(+), 13 deletions(-)
diff --git a/src/app/dashboard/resume-builder/page.tsx b/src/app/dashboard/resume-builder/page.tsx
index 188f8f9..917044b 100644
--- a/src/app/dashboard/resume-builder/page.tsx
+++ b/src/app/dashboard/resume-builder/page.tsx
@@ -53,7 +53,7 @@ type ResumeBuilderSourceDraft =
interface ResumeBuilderDraft {
source: ResumeBuilderSourceDraft
template?: {
- templateId: 'awesome-classic' | 'deedy-modern' | 'sb2nov-ats' | 'custom'
+ templateId: 'jake-classic' | 'deedy-modern' | 'sb2nov-ats' | 'custom'
customTemplateName?: string
customTemplateLatex?: string
}
diff --git a/src/app/dashboard/resume-builder/step-2/page.tsx b/src/app/dashboard/resume-builder/step-2/page.tsx
index 35a2372..9810896 100644
--- a/src/app/dashboard/resume-builder/step-2/page.tsx
+++ b/src/app/dashboard/resume-builder/step-2/page.tsx
@@ -80,7 +80,7 @@ export default function ResumeBuilderStep2Page() {
const [isBooting, setIsBooting] = useState(true)
const [draft, setDraft] = useState(null)
- const [templateId, setTemplateId] = useState('awesome-classic')
+ const [templateId, setTemplateId] = useState('jake-classic')
const [customTemplateName, setCustomTemplateName] = useState(null)
const [customTemplateLatex, setCustomTemplateLatex] = useState(null)
diff --git a/src/lib/contracts/api.ts b/src/lib/contracts/api.ts
index 2b13c13..9f77955 100644
--- a/src/lib/contracts/api.ts
+++ b/src/lib/contracts/api.ts
@@ -3,7 +3,7 @@ import { z } from 'zod';
export const toneSchema = z.enum(['professional', 'friendly', 'enthusiastic']);
export const lengthSchema = z.enum(['concise', 'standard', 'detailed']);
export const analysisTypeSchema = z.enum(['overview', 'keywords', 'match', 'coverLetter']);
-export const resumeTemplateIdSchema = z.enum(['awesome-classic', 'deedy-modern', 'sb2nov-ats', 'custom']);
+export const resumeTemplateIdSchema = z.enum(['jake-classic', 'deedy-modern', 'sb2nov-ats', 'custom']);
const freeTextSchema = z
.string()
@@ -56,7 +56,7 @@ export const coverLetterRequestSchema = analyzeRequestSchema
export const tailoredResumeRequestSchema = z.object({
resumeText: z.string().trim().min(1, 'Resume text is required').max(50000, 'Resume text is too long (max 50,000 characters)'),
jobDescription: z.string().trim().min(1, 'Job description is required').max(15000, 'Job description is too long (max 15,000 characters)'),
- templateId: resumeTemplateIdSchema.default('awesome-classic'),
+ templateId: resumeTemplateIdSchema.default('jake-classic'),
resumeName: optionalFreeTextSchema,
builderSlug: z.string().trim().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Invalid builder slug format').min(4).max(120).optional(),
sourceAnalysisId: z.string().trim().min(1).max(128).optional(),
diff --git a/src/lib/resume-latex.ts b/src/lib/resume-latex.ts
index 11bb059..97a2c10 100644
--- a/src/lib/resume-latex.ts
+++ b/src/lib/resume-latex.ts
@@ -1,6 +1,7 @@
import type { TailoredResumeData, TailoredResumeSectionItem } from '@/lib/gemini';
-export const BUILT_IN_RESUME_TEMPLATE_IDS = ['awesome-classic', 'deedy-modern', 'sb2nov-ats'] as const;
+export const BUILT_IN_RESUME_TEMPLATE_IDS = ['jake-classic', 'deedy-modern', 'sb2nov-ats'] as const;
+export const JAKE_CLASSIC_TEMPLATE_PUBLIC_PATH = "/jake's_resume.tex";
export type BuiltInResumeTemplateId = (typeof BUILT_IN_RESUME_TEMPLATE_IDS)[number];
export type ResumeTemplateId = BuiltInResumeTemplateId | 'custom';
@@ -14,9 +15,9 @@ export interface ResumeTemplateOption {
export const RESUME_TEMPLATE_OPTIONS: ResumeTemplateOption[] = [
{
- id: 'awesome-classic',
+ id: 'jake-classic',
name: "Jake's Resume",
- description: "Jake Gutierrez's classic one-page LaTeX resume template from Overleaf.",
+ description: "Jake Gutierrez's classic one-page LaTeX resume template (public/jake's_resume.tex).",
atsFriendly: true,
},
{
@@ -215,7 +216,7 @@ function renderBody(templateId: BuiltInResumeTemplateId, data: TailoredResumeDat
const certifications = cleanList(data.certifications, 8);
const additional = cleanList(data.additional, 8);
- if (templateId === 'awesome-classic') {
+ if (templateId === 'jake-classic') {
const skillsInline = skills.map((item) => escapeLatex(item)).join(', ');
const sections = [
renderJakeEntrySection('Education', education),
@@ -342,7 +343,7 @@ export function buildLatexResume(templateId: BuiltInResumeTemplateId, rawData: T
const preamble = buildTemplatePreamble(templateId);
- if (templateId === 'awesome-classic') {
+ if (templateId === 'jake-classic') {
const contactParts = renderJakeContact(data);
return `${preamble}
\\begin{center}
@@ -374,7 +375,7 @@ function toJsonString(value: unknown): string {
}
export function buildLatexResumeFromCustomTemplate(templateSource: string, rawData: TailoredResumeData): string {
- const builtInFallback = buildLatexResume('awesome-classic', rawData);
+ const builtInFallback = buildLatexResume('jake-classic', rawData);
const experience = cleanSectionItems(rawData.experience, 6);
const projects = cleanSectionItems(rawData.projects, 6);
const education = cleanSectionItems(rawData.education, 4);
diff --git a/test/app/api/generate-resume-latex/route.test.ts b/test/app/api/generate-resume-latex/route.test.ts
index 65973fc..7adcf4f 100644
--- a/test/app/api/generate-resume-latex/route.test.ts
+++ b/test/app/api/generate-resume-latex/route.test.ts
@@ -57,7 +57,7 @@ describe('/api/generate-resume-latex', () => {
it('returns cached tailored resume when available', async () => {
vi.mocked(getTailoredResume).mockResolvedValue({
_id: 'tr-cache',
- templateId: 'awesome-classic',
+ templateId: 'jake-classic',
latexSource: '\\documentclass{article}',
structuredData: JSON.stringify({ summary: 'cached' }),
} as never);
@@ -67,7 +67,7 @@ describe('/api/generate-resume-latex', () => {
body: JSON.stringify({
resumeText: 'resume',
jobDescription: 'job',
- templateId: 'awesome-classic',
+ templateId: 'jake-classic',
}),
});
diff --git a/test/lib/resume-latex.test.ts b/test/lib/resume-latex.test.ts
index 7033cc7..4b68c52 100644
--- a/test/lib/resume-latex.test.ts
+++ b/test/lib/resume-latex.test.ts
@@ -15,7 +15,7 @@ describe('resume-latex', () => {
});
it('builds latex document for template', () => {
- const output = buildLatexResume('awesome-classic', {
+ const output = buildLatexResume('jake-classic', {
fullName: 'Ada Lovelace',
email: 'ada@example.com',
summary: 'Engineer building reliable systems.',