diff --git a/CLAUDE.md b/CLAUDE.md index 1b699bd..34b78cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -408,6 +408,7 @@ Do not suppress warnings. Do not add `// eslint-disable` unless the finding is a | 10 | Rate limiting on `/api/generate` | **Ready** — blocked on #1, #3 | | 11 | Stable job identity (UUID) — fix silent data loss on delete/move for duplicate company+role | **Ready** — blocked on #5 | | 16 | "Evaluate Job URL" flow — fetch → Playwright fallback, streaming status, AI fit scoring, user-confirm before write to pending | **Ready** — blocked on #5, #10 | +| 17 | Styled PDF resume export (Playwright + HTML/CSS template) — `stylePdfEnabled` config flag, `resume-template.ts`, `resume-pdf.ts`, `/api/export/resume-pdf` route | **Done** | **Starting a slice:** Load only the files listed in the Token Routing table for this task type. Write the Z test. Run it. Confirm it fails. Then proceed. diff --git a/app/api/__tests__/resume-pdf-route.test.ts b/app/api/__tests__/resume-pdf-route.test.ts new file mode 100644 index 0000000..4c18ec3 --- /dev/null +++ b/app/api/__tests__/resume-pdf-route.test.ts @@ -0,0 +1,86 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { POST } from "@/app/api/export/resume-pdf/route"; +import { readConfig } from "@/lib/config-repository"; +import { githubRead } from "@/lib/github"; +import { renderResumePdf } from "@/lib/resume-pdf"; +import type { AppConfig } from "@/lib/config"; + +vi.mock("@/lib/config-repository", () => ({ + readConfig: vi.fn(), +})); + +vi.mock("@/lib/github", () => ({ + githubRead: vi.fn(), +})); + +vi.mock("@/lib/resume-pdf", () => ({ + renderResumePdf: vi.fn(), +})); + +function request(body: unknown = {}) { + return new NextRequest("http://localhost/api/export/resume-pdf", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +function configWith(stylePdfEnabled: boolean): AppConfig { + return { export: { stylePdfEnabled } }; +} + +beforeEach(() => { + vi.mocked(githubRead).mockResolvedValue(JSON.stringify({ name: "Jordan Rivera" })); + vi.mocked(renderResumePdf).mockReset(); +}); + +describe("POST /api/export/resume-pdf", () => { + it("returns 400 when styled PDF export is disabled in config", async () => { + vi.mocked(readConfig).mockResolvedValue(configWith(false)); + + const response = await POST(request()); + + expect(response.status).toBe(400); + expect(renderResumePdf).not.toHaveBeenCalled(); + }); + + it("returns 400 when export config is entirely absent (defaults to disabled)", async () => { + vi.mocked(readConfig).mockResolvedValue({}); + + const response = await POST(request()); + + expect(response.status).toBe(400); + }); + + it("returns 503 when config allows it but the renderer returns null (Playwright unavailable)", async () => { + vi.mocked(readConfig).mockResolvedValue(configWith(true)); + vi.mocked(renderResumePdf).mockResolvedValue(null); + + const response = await POST(request()); + + expect(response.status).toBe(503); + }); + + it("returns a PDF with the correct headers on success", async () => { + vi.mocked(readConfig).mockResolvedValue(configWith(true)); + vi.mocked(renderResumePdf).mockResolvedValue(Buffer.from("pdf-bytes")); + + const response = await POST(request({ company: "Acme Corp", role: "Design Engineer" })); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("application/pdf"); + expect(response.headers.get("Content-Disposition")).toContain("jordan-rivera-acme-corp-design-engineer.pdf"); + }); + + it("returns 500 with an error message when profile.json fails to parse", async () => { + vi.mocked(readConfig).mockResolvedValue(configWith(true)); + vi.mocked(githubRead).mockResolvedValue("not json"); + + const response = await POST(request()); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error).toBeTruthy(); + }); +}); diff --git a/app/api/export/resume-pdf/route.ts b/app/api/export/resume-pdf/route.ts new file mode 100644 index 0000000..71839e4 --- /dev/null +++ b/app/api/export/resume-pdf/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { githubRead } from "@/lib/github"; +import { readConfig } from "@/lib/config-repository"; +import { resolveExportStyle } from "@/lib/config"; +import { buildResumeHtml } from "@/lib/resume-template"; +import { renderResumePdf } from "@/lib/resume-pdf"; +import type { ProfileData } from "@/lib/docx-resume"; +import { resumeFilenameSlug } from "@/lib/resume-format"; + +export const runtime = "nodejs"; + +export async function POST(req: NextRequest) { + try { + const body = await req.json() as { + company?: string; + role?: string; + tailoredBullets?: Record; + tailoredProfileBullets?: string[]; + }; + + const [config, profileRaw] = await Promise.all([ + readConfig(), + githubRead("data/profile.json"), + ]); + + const style = resolveExportStyle(config.export); + if (!style.stylePdfEnabled) { + return NextResponse.json( + { error: "Styled PDF export is disabled. Enable it under Settings > Export." }, + { status: 400 } + ); + } + + const profile = JSON.parse(profileRaw) as ProfileData; + const candidate = config.candidate ?? {}; + + const html = buildResumeHtml(profile, candidate, { + tailoredBullets: body.tailoredBullets, + company: body.company, + tailoredProfileBullets: body.tailoredProfileBullets, + }); + + const pdfBuffer = await renderResumePdf({ html }); + if (!pdfBuffer) { + return NextResponse.json( + { error: "Styled PDF export requires Playwright, which is not available in this deployment." }, + { status: 503 } + ); + } + + const company = body.company ?? "company"; + const role = body.role ?? "role"; + const name = profile.name ?? candidate.name; + const filename = `${resumeFilenameSlug(name, company, role)}.pdf`; + + return new NextResponse(pdfBuffer as unknown as BodyInit, { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/export/resume/route.ts b/app/api/export/resume/route.ts index 8b2a49f..1c21432 100644 --- a/app/api/export/resume/route.ts +++ b/app/api/export/resume/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { githubRead } from "@/lib/github"; import { readConfig } from "@/lib/config-repository"; import { generateResumeDOCX, type ProfileData } from "@/lib/docx-resume"; +import { resumeFilenameSlug } from "@/lib/resume-format"; export async function POST(req: NextRequest) { try { @@ -25,8 +26,8 @@ export async function POST(req: NextRequest) { const company = body.company ?? "company"; const role = body.role ?? "role"; - const slug = `${company}-${role}`.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); - const filename = `resume-${slug}.docx`; + const name = profile.name ?? candidate.name; + const filename = `${resumeFilenameSlug(name, company, role)}.docx`; return new NextResponse(buffer as unknown as BodyInit, { headers: { diff --git a/app/api/tailor-resume/route.ts b/app/api/tailor-resume/route.ts index c99759d..c7b8ab6 100644 --- a/app/api/tailor-resume/route.ts +++ b/app/api/tailor-resume/route.ts @@ -32,15 +32,19 @@ export async function POST(req: NextRequest) { const systemPrompt = buildSystemPrompt(profile); const experienceList = (profile.experience ?? []) - .map((e) => `- ${e.role} at ${e.company}`) - .join("\n"); + .map((e, idx) => { + const budget = idx < 2 ? 4 : 2; + const pool = e.bullets.map((b, i) => ` ${i + 1}. ${b}`).join("\n"); + return `- ${e.role} at ${e.company} (select ${budget} bullets)\n Available pool:\n${pool}`; + }) + .join("\n\n"); const userPrompt = `Tailor this candidate's resume for the following role. Return only valid JSON — no prose, no markdown fences. ROLE: ${role} at ${company} ${jdText ? `\nJOB DESCRIPTION:\n${jdText.slice(0, 6000)}` : ""} -CANDIDATE'S CURRENT EXPERIENCE POSITIONS (use exact company and role names): +CANDIDATE'S EXPERIENCE — bullet pools per role (use exact company and role names): ${experienceList} Return a JSON object with this exact shape: @@ -55,7 +59,7 @@ Return a JSON object with this exact shape: Rules: - title: mirror the target role's language, max 3 segments - profileBullets: reorder or lightly reword the existing 4 profile bullets to front-load what this JD emphasizes most — do not invent new ones -- experience: reorder and lightly reword bullets to match JD keywords — do not fabricate experience; include all positions from the candidate's history +- experience: for each role, choose the BEST bullets from its available pool and lightly reword them to match JD keywords — do not fabricate bullets not in the pool; include all positions; the two most recent roles get exactly 4 bullets, all older roles get exactly 2 bullets - Apply all writing style rules from the system prompt - Return only the JSON object, nothing else`; diff --git a/app/settings/export/page.tsx b/app/settings/export/page.tsx index d1aef8d..df3887c 100644 --- a/app/settings/export/page.tsx +++ b/app/settings/export/page.tsx @@ -176,6 +176,26 @@ export default function ExportSettings() {

+
+

Styled PDF resume (beta)

+ +

+ Renders a design-forward PDF resume (Inter, custom colors) alongside the DOCX export. Requires a + self-hosted deployment with ENABLE_PLAYWRIGHT_FALLBACK=true — + it will not work on Vercel or other serverless hosts. +

+
+
- -