diff --git a/.env.example b/.env.example index ab9ae7a..bb389e3 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,8 @@ 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" +LATEX_RENDER_API_BASE="https://latexonline.cc" # Convex Variables NEXT_PUBLIC_CONVEX_URL="" diff --git a/README.md b/README.md index e3f1dea..1eccc4c 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,8 @@ 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" +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 @@ -82,15 +85,3 @@ bun run dev ``` App runs at `http://localhost:3000`. - -## Scripts - -```bash -bun run dev # Start Next.js dev server -bun run build # Build app -bun run start # Start production server -bun run lint # Run ESLint -bun run test # Run tests once (Vitest) -bun run test:watch # Run tests in watch mode -bun run setup-db # One-time Convex setup (convex dev --once) -``` diff --git a/convex/functions.ts b/convex/functions.ts index 7607d5d..84010a8 100644 --- a/convex/functions.ts +++ b/convex/functions.ts @@ -245,6 +245,108 @@ 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(), + 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); + 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; + }, +}); + +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({ @@ -320,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; @@ -328,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, @@ -349,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 e2ee07d..ab8f0ea 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -40,4 +40,25 @@ 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(), + 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_userId_builderSlug", ["userId", "builderSlug"]), }); 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/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 new file mode 100644 index 0000000..32a5ae2 --- /dev/null +++ b/src/app/api/generate-resume-latex/route.ts @@ -0,0 +1,209 @@ +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, + getTailoredResumeVersionsBySlug, + saveTailoredResume, +} from '@/lib/convex-server'; +import { generateTailoredResumeData } from '@/lib/gemini'; +import { getIdempotentResponse, setIdempotentResponse } from '@/lib/idempotency'; +import { buildLatexResume, buildLatexResumeFromCustomTemplate } 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, + 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; + + 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, templateLookupId); + if (cached) { + let structuredData: Record = {}; + try { + structuredData = JSON.parse(cached.structuredData) as Record; + } catch { + 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: true, + source: 'database' as const, + documentId, + builderSlug, + 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 = 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: 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) { + console.error('Error saving tailored resume', { requestId, error: saveError }); + } + + const response = { + latexSource, + structuredData, + templateId, + cached: false, + documentId, + builderSlug, + version: builderSlug ? resolvedVersion : undefined, + 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/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/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() { 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/[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')} +

+ )} +
+
+
+ +
+ + + {historyOpen && ( +
+ {versions.map((version) => ( + + ))} +
+ )} +
+ +
+
+
+

LaTeX Editor

+
+ + + + +
+
+