For contributors. Everything you need to extend the app locally.
- Framework Next.js 15, App Router, TypeScript strict, React 19.
- Database PostgreSQL 16 (pgvector image, though no pgvector usage yet).
- ORM Drizzle ORM with
drizzle-kitfor generate + migrate. - LLM Anthropic SDK (
@anthropic-ai/sdk) and Google Gen AI SDK (@google/genai) behind a singleLlmProviderinterface. - PDF Playwright-driven headless Chromium.
- Styling Tailwind CSS v4 with CSS variable tokens.
- Tables TanStack Table v8 on
/applicationsand/gaps. - DnD
@dnd-kit/coreon the Applications board view. - Toasts
sonner. - Deps
zodfor validation,sharpfor image resize,lucide-reactfor icons,react-markdown+remark-gfm.
rolehunter/
Dockerfile multi-stage image; Chromium baked in, runs as `nextjs` user
docker-compose.yml app + db, both bound to 127.0.0.1
scripts/
setup.sh one-shot env generation (random free ports)
backup.sh pg_dump + uploads tar
src/
app/
api/ route handlers grouped by feature
<page>/ server components per route
components/
dashboard/ dashboard section components
gaps/ /gaps UI
applications/ /applications table and row editors
*.tsx shared panels (match, cv-rewrite, interviews, flashcards, ...)
lib/
db/
schema.ts Drizzle schema, single source of truth
migrations/ generated .sql files (DO NOT edit by hand)
index.ts getDb() pool singleton
repo/ one file per aggregate (cv, jobs, matches, applications, gaps, ...)
llm/
types.ts LlmProvider interface + result types
prompts.ts every SYSTEM_* prompt string
claude.ts Anthropic SDK impl
gemini.ts Google Gen AI SDK impl
index.ts getProvider(name) factory with fallback
pdf/
cv-html.ts CV renderer with themes, avatar embedding, dedup helpers
cover-letter-html.ts shared cover-letter renderer
render.ts Playwright singleton wrapper
avatar.ts reads profile.avatarPath, returns data URI
jsearch/client.ts RapidAPI JSearch wrapper
linkedin/ RapidAPI Fantastic Jobs wrapper + normalise
api.ts wrap() helper for structured error JSON
env.ts zod-validated environment access
doc/
onboarding.md
starting.md
development.md you are here
scripts/capture-screenshots.mjs Playwright doc-screenshotter
Drop NODE_ENV on the host to let npm install devDependencies, then point the app at your containerised Postgres.
unset NODE_ENV
npm install
source .env
DATABASE_URL="postgres://rolehunter:${DB_PASSWORD}@127.0.0.1:${DB_PORT}/rolehunter" \
npx drizzle-kit migrate
npm run devnext dev uses the same env vars. Playwright Chromium on the host won't match the container's version, so PDF export will fail in dev — run npx playwright install chromium if you need it.
- Edit
src/lib/db/schema.ts. - Regenerate:
unset NODE_ENV && DATABASE_URL=... npx drizzle-kit generate. This writes a new.sqltosrc/lib/db/migrations/. - Read the generated SQL, add any backfill statements manually if needed.
- Apply:
DATABASE_URL=... npx drizzle-kit migrate. - Rebuild the container so the new migration ships:
docker compose up -d --build app.
The runner stage bundles the migrations folder, so on first container start the migrations directory is there for drizzle-kit migrate to apply.
- Define the result type in
src/lib/llm/types.tsand add the method toLlmProvider. - Write a
SYSTEM_*prompt insrc/lib/llm/prompts.ts. Return ONLY JSON to keep parsing simple. Include good/bad examples inline for tricky prompts (seeSYSTEM_REWRITE_CV). - Implement in both
src/lib/llm/claude.tsandsrc/lib/llm/gemini.ts. Both files importparseJsonfrom their own local helper; match the existing pattern. - Budget output tokens conservatively — Claude Sonnet 4.6 caps at 64K output, Gemini 2.5 Pro at 65K, but cap yours to what the prompt realistically needs plus a 1.5x safety margin. Truncation produces invalid JSON and the route will surface a helpful error via
wrap().
Call sites look like getProvider(provider).myMethod(...). The factory auto-falls back to the other provider if the requested one's API key is empty.
-
Create
src/app/api/<path>/route.ts. -
Export HTTP methods wrapped in
wrap()from@/lib/api:import { NextResponse } from "next/server"; import { z } from "zod"; import { wrap } from "@/lib/api"; export const runtime = "nodejs"; export const maxDuration = 60; // for LLM-calling routes, use 120 or 180 const body = z.object({ ... }); export const POST = wrap(async (req: Request) => { const parsed = body.safeParse(await req.json()); if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); const result = await myRepoFunction(parsed.data); return NextResponse.json(result); });
-
For dynamic routes, Next 15 async params:
export const PATCH = wrap(async (req, ctx: { params: Promise<{ id: string }> }) => { const { id } = await ctx.params; ... });
-
Client code parses responses defensively:
const text = await res.text(); const json = text ? JSON.parse(text) : null; if (!res.ok) throw new Error(json?.error ?? `failed (${res.status})`);
wrap()guarantees every error comes back as{ error: "..." }so this pattern never hits "Unexpected end of JSON input".
src/app/<route>/page.tsxas a server component,export const dynamic = "force-dynamic"when it reads DB.- Fetch data via repo functions, pass as props to client components.
- Add to the nav array in
src/app/layout.tsx.
- TypeScript strict, no
any. Useunknownand narrow with predicates or zod. - No emoji in source or docs unless the user explicitly asks.
- Tailwind tokens, not hex:
bg-[var(--background)],text-[var(--muted-foreground)],border-[var(--border)],text-[var(--accent)],text-[var(--success)],text-[var(--warning)],text-[var(--danger)]. - Client errors go through
sonner'stoast.error(...); neverconsole.erroronly. - Radix primitives for any dialog, dropdown, tabs, select.
There is no automated test suite yet. Manual smoke test after any change:
unset NODE_ENV
npx next build # type-check + build
docker compose up -d --build app
source .env
curl -s http://127.0.0.1:${APP_PORT}/api/health # expect providers true where keys setFollow-up issue: add GitHub Actions CI for typecheck + build.
The 11 page screenshots are captured by doc/scripts/capture-screenshots.mjs using the container's baked-in Playwright. Before running, swap the profile to dummy values (Jane Doe / jane.doe@example.com / clear avatarPath) so nothing personal lands in the images. After running, restore the real profile.
docker cp doc/scripts/capture-screenshots.mjs rolehunter-app-1:/app/capture.mjs
docker compose exec -T app sh -c 'cd /app && mkdir -p /app/shots && APP_URL=http://127.0.0.1:3000 OUT_DIR=/app/shots node capture.mjs'
docker compose cp app:/app/shots /tmp/shots
cp /tmp/shots/*.png doc/images/All unimplemented items are filed as issues on the repo:
enhancement,ai— LLM-side features (gap flashcards, insert-bullet sidebar, pgvector canonicalisation).enhancement,ux— UX polish (two-column Modern CV, multi-select bulk actions).enhancement,integration— third-party integrations (.icsexport, email send, LinkedIn clipper).infra— CI, Docker publish, retries, backups.accessibility— a11y audit across tables and modals.
PRs welcome. Keep them tight and scoped to one issue where possible.