Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
86 changes: 86 additions & 0 deletions app/api/__tests__/resume-pdf-route.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
66 changes: 66 additions & 0 deletions app/api/export/resume-pdf/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>;
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 });
}
}
5 changes: 3 additions & 2 deletions app/api/export/resume/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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: {
Expand Down
12 changes: 8 additions & 4 deletions app/api/tailor-resume/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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`;

Expand Down
20 changes: 20 additions & 0 deletions app/settings/export/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,26 @@ export default function ExportSettings() {
</p>
</div>

<div className={SECTION}>
<h2 className="text-sm font-semibold text-gray-800 dark:text-white">Styled PDF resume (beta)</h2>
<label className="flex items-center gap-2.5 cursor-pointer select-none w-fit">
<input
type="checkbox"
className="w-4 h-4 accent-primary"
checked={(get("stylePdfEnabled") as boolean) ?? false}
onChange={(e) => updateExport("stylePdfEnabled", e.target.checked)}
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Enable styled PDF export
</span>
</label>
<p className="text-xs text-muted-foreground ml-6 -mt-2">
Renders a design-forward PDF resume (Inter, custom colors) alongside the DOCX export. Requires a
self-hosted deployment with <code className="bg-muted px-1 rounded">ENABLE_PLAYWRIGHT_FALLBACK=true</code> —
it will not work on Vercel or other serverless hosts.
</p>
</div>

<div className="flex justify-end">
<Button onClick={save} loading={saving} size="lg" className="px-5">
Save export settings
Expand Down
84 changes: 69 additions & 15 deletions app/settings/profile-ai/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import type { Profile, ExperienceEntry, EducationEntry } from "@/lib/profile";
import type { Profile, ExperienceEntry, EducationEntry, StrengthGroup } from "@/lib/profile";
import { linesToBullets } from "@/lib/resume-format";

const INPUT = "w-full border border-border rounded-lg px-3 py-2 text-sm bg-card dark:text-white focus:outline-none focus:ring-2 focus:ring-primary";
const LABEL = "block text-xs font-semibold text-muted-foreground uppercase tracking-widest mb-1";
Expand All @@ -17,14 +18,38 @@ export default function ProfileAiSettings() {
useEffect(() => {
fetch("/api/profile")
.then((r) => r.json())
.then((data) => { setProfile(data); setLoading(false); })
.then((data: Profile) => {
let migrated = data;
if (!migrated.strengthGroups?.length && migrated.strengths?.length) {
migrated = { ...migrated, strengthGroups: [{ label: "Strengths", items: migrated.strengths }] };
}
if (!migrated.summaryBullets?.length && migrated.summary) {
migrated = { ...migrated, summaryBullets: linesToBullets(migrated.summary) };
}
setProfile(migrated);
setLoading(false);
})
.catch(() => { toast.error("Failed to load profile"); setLoading(false); });
}, []);

function update(patch: Partial<Profile>) {
setProfile((p) => ({ ...p, ...patch }));
}

function updateStrengthGroup(idx: number, patch: Partial<StrengthGroup>) {
const next = [...(profile.strengthGroups ?? [])];
next[idx] = { ...next[idx], ...patch };
update({ strengthGroups: next });
}

function addStrengthGroup() {
update({ strengthGroups: [...(profile.strengthGroups ?? []), { label: "", items: [] }] });
}

function removeStrengthGroup(idx: number) {
update({ strengthGroups: (profile.strengthGroups ?? []).filter((_, i) => i !== idx) });
}

function updateExp(idx: number, patch: Partial<ExperienceEntry>) {
const next = [...(profile.experience ?? [])];
next[idx] = { ...next[idx], ...patch };
Expand Down Expand Up @@ -128,8 +153,14 @@ export default function ProfileAiSettings() {
<input className={INPUT} placeholder="Senior UX Designer / Director" value={profile.title ?? ""} onChange={(e) => update({ title: e.target.value })} />
</div>
<div className="col-span-2">
<label className={LABEL}>Professional summary</label>
<textarea rows={3} className={INPUT} placeholder="2–3 sentences used in generated cover letters" value={profile.summary ?? ""} onChange={(e) => update({ summary: e.target.value })} />
<label className={LABEL}>Profile summary (bullet points, one per line)</label>
<textarea
rows={4}
className={INPUT}
placeholder={"20+ years leading enterprise UX, product strategy, and design systems\nProven track record building and scaling high-performing design orgs"}
value={(profile.summaryBullets ?? []).join("\n")}
onChange={(e) => update({ summaryBullets: linesToBullets(e.target.value) })}
/>
</div>
<div>
<label className={LABEL}>Portfolio URL</label>
Expand All @@ -140,14 +171,37 @@ export default function ProfileAiSettings() {
<input className={INPUT} value={profile.portfolio_password ?? ""} onChange={(e) => update({ portfolio_password: e.target.value })} />
</div>
</div>
<div>
<label className={LABEL}>Core strengths (one per line)</label>
<textarea
rows={3}
className={INPUT}
value={(profile.strengths ?? []).join("\n")}
onChange={(e) => update({ strengths: e.target.value.split("\n").map((s) => s.trim()).filter(Boolean) })}
/>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className={LABEL}>Core strengths</label>
<Button onClick={addStrengthGroup} variant="link" size="sm" className="text-xs">+ Add skill group</Button>
</div>
<p className="text-xs text-muted-foreground">
Optionally split strengths into labeled groups (e.g. UX &amp; Design, Tools, Technical). One group is fine too.
</p>
{(profile.strengthGroups ?? []).map((group, gi) => (
<div key={gi} className="border border-border rounded-xl p-3 space-y-2">
<div className="flex gap-2 items-center">
<input
className={INPUT}
placeholder="Group label, e.g. UX & Design"
value={group.label}
onChange={(e) => updateStrengthGroup(gi, { label: e.target.value })}
/>
<button onClick={() => removeStrengthGroup(gi)} className={`${BTN_SM} shrink-0 hover:text-destructive`}>×</button>
</div>
<textarea
rows={2}
className={INPUT}
placeholder="Comma-separated items, e.g. Product Design, UX Research, Design Systems"
value={group.items.join(", ")}
onChange={(e) => updateStrengthGroup(gi, { items: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })}
/>
</div>
))}
{(profile.strengthGroups ?? []).length === 0 && (
<p className="text-sm text-muted-foreground">No skill groups yet.</p>
)}
</div>
</div>

Expand Down Expand Up @@ -207,15 +261,15 @@ export default function ProfileAiSettings() {
<div key={i} className="grid grid-cols-2 gap-3 border border-border rounded-xl p-4">
<div className="col-span-2">
<label className={LABEL}>Institution</label>
<input className={INPUT} value={edu.institution} onChange={(e) => updateEdu(i, { institution: e.target.value })} />
<input className={INPUT} value={edu.institution ?? ""} onChange={(e) => updateEdu(i, { institution: e.target.value })} />
</div>
<div>
<label className={LABEL}>Degree</label>
<input className={INPUT} value={edu.degree} onChange={(e) => updateEdu(i, { degree: e.target.value })} />
<input className={INPUT} value={edu.degree ?? ""} onChange={(e) => updateEdu(i, { degree: e.target.value })} />
</div>
<div>
<label className={LABEL}>Graduated</label>
<input className={INPUT} placeholder="2020-05" value={edu.graduated} onChange={(e) => updateEdu(i, { graduated: e.target.value })} />
<input className={INPUT} placeholder="2020-05" value={edu.graduated ?? ""} onChange={(e) => updateEdu(i, { graduated: e.target.value })} />
</div>
<div className="col-span-2 flex items-end justify-between gap-3">
<div className="flex-1">
Expand Down
Binary file added assets/fonts/inter/Inter-Regular.woff2
Binary file not shown.
Binary file added assets/fonts/inter/Inter-SemiBold.woff2
Binary file not shown.
Loading
Loading