diff --git a/README.md b/README.md index bcc14fa..68e3892 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Shortlist -**AI-powered job search, end to end.** Shortlist scrapes listings from Greenhouse, Lever, and Ashby every morning, scores them against your profile using Claude, lets you tailor your resume with one click, and tracks your entire pipeline — all in one place. +[](https://github.com/mojoro/shortlist/actions/workflows/ci.yml) + +**AI-powered job search, end to end.** Shortlist scrapes listings from six sources every morning, scores them against your profile with AI, lets you tailor your resume with one click, and tracks your entire pipeline — all in one place. > Built by [John Moorman](https://johnmoorman.com) as a portfolio project. Currently single-user; SaaS roadmap in progress. @@ -14,17 +16,7 @@ - **Pipeline tracker** — Kanban-style table: Saved → Applied → Interview → Offer. Tracks follow-up dates, notes, and the tailored resume used for each application. - **Import anything** — Paste a URL or raw text from any job listing. Claude extracts the structured fields. Works with any company, not just supported ATS platforms. - **Multi-profile** — One account, multiple independent job searches. "Frontend Berlin" and "Automation Remote" get completely separate feeds, criteria, and pipelines. -- **Daily scraping** — Greenhouse, Lever, and Ashby jobs scraped every morning via Vercel Cron. Pool-first architecture deduplicates globally before matching per-profile. - ---- - -## Screenshots - - - - - - +- **Daily scraping** — Six sources (Greenhouse, Lever, Ashby, USAJobs, Adzuna, Arbeitnow) scraped every morning via Vercel Cron. Pool-first architecture deduplicates globally before matching per-profile. --- @@ -38,8 +30,11 @@ | Database | [Neon](https://neon.tech) (PostgreSQL) | | ORM | Prisma | | Auth | [Clerk](https://clerk.com) | -| AI | [OpenRouter](https://openrouter.ai) → `anthropic/claude-sonnet-4-6` | -| Scraping | Greenhouse / Lever / Ashby public APIs | +| AI | [OpenRouter](https://openrouter.ai) — multiple models (see AI pipeline below) | +| Scraping | Greenhouse, Lever, Ashby, USAJobs, Adzuna, Arbeitnow | +| State | Zustand (client), React 19 `useOptimistic` | +| Testing | Playwright (E2E), Vitest + React Testing Library (unit) | +| CI/CD | GitHub Actions (typecheck → lint → unit → Playwright) | | Scheduling | Vercel Cron (daily at 7am UTC) | | PDF | `@react-pdf/renderer` | | Markdown | `@uiw/react-md-editor` | @@ -69,9 +64,12 @@ User (Clerk ID) ### AI pipeline -- **Scoring** — Batches of 5 jobs, 500ms between batches, `claude-haiku-4-5`. Pre-filter rejects excluded keywords without an API call. Response: `{ score, status, summary, matchPoints[], gapPoints[] }`. -- **Tailoring** — Streaming, `claude-sonnet-4-6`. Uses the full CV as content source and the master resume as format template. Writing rules are injected as hard constraints. -- **Extraction** — One-shot, `claude-haiku-4-5`. Fetches a URL, strips HTML noise, converts to markdown, extracts structured fields. +- **Scoring** — Batches of 5 jobs, 500ms between batches, `anthropic/claude-haiku-4.5`. Pre-filter rejects excluded keywords without an API call. Response: `{ score, status, summary, matchPoints[], gapPoints[] }`. +- **Tailoring** — Streaming, `qwen/qwen3.5-397b-a17b`. Uses the full CV as content source and the master resume as format template. Writing rules are injected as hard constraints. +- **Extraction** — One-shot, `anthropic/claude-haiku-4.5`. Fetches a URL, strips HTML noise, converts to markdown, extracts structured fields. +- **Triage** — Batch classification of borderline candidates, `google/gemini-2.5-flash`. + +All models are user-overridable per profile via Advanced Settings. --- @@ -117,13 +115,16 @@ curl -X POST http://localhost:3000/api/dev/seed | Variable | Description | |---|---| | `DATABASE_URL` | Neon pooled connection string | -| `DIRECT_URL` | Neon direct connection string (migrations only) | +| `DATABASE_URL_UNPOOLED` | Neon direct connection string (migrations only) | | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk publishable key | | `CLERK_SECRET_KEY` | Clerk secret key | -| `CLERK_WEBHOOK_SECRET` | Clerk webhook signing secret | +| `CLERK_WEBHOOK_SECRET` | Clerk webhook signing secret (optional) | | `OPENROUTER_API_KEY` | OpenRouter API key | -| `APIFY_API_TOKEN` | Apify token (reserved, not yet used) | | `CRON_SECRET` | Random string — protects `/api/scrape` and `/api/analyze` | +| `USAJOBS_API_KEY` | USAJobs scraper (optional) | +| `USAJOBS_EMAIL` | USAJobs scraper (optional) | +| `ADZUNA_APP_ID` | Adzuna scraper (optional) | +| `ADZUNA_APP_KEY` | Adzuna scraper (optional) | | `NEXT_PUBLIC_APP_URL` | Full URL of the deployment (e.g. `http://localhost:3000`) | | `NEXT_PUBLIC_DEFAULT_THEME` | `light` \| `dark` \| `system` | @@ -143,42 +144,71 @@ Add `?skipPool=1` to skip re-scraping and re-run the matching pass against the e ``` src/ app/ - (auth)/ # Clerk sign-in / sign-up pages + (auth)/ # Clerk sign-in / sign-up pages (dashboard)/ - dashboard/ # Job feed - jobs/[id]/ # Job detail + match analysis - tailor/[jobId]/ # Resume tailor — JD vs resume, streaming, export - pipeline/ # Application tracker + dashboard/ # Job feed — default view + jobs/[id]/ # Job detail + match analysis + tailor/[jobId]/ # Resume tailor — JD vs resume, streaming, export + pipeline/ # Application tracker (table + Kanban board) + settings/ # Profile + Account tabs api/ - scrape/ # POST — pool scrape + profile matching - analyze/ # POST — AI scoring for unscored jobs - tailor/ # POST — streams tailored resume - jobs/extract/ # POST — AI field extraction from URL/text - jobs/import/ # POST — saves a custom job listing - page.tsx # Landing page + scrape/ # POST — pool scrape + profile matching + analyze/ # POST — AI scoring for unscored jobs + tailor/ # POST — streams tailored resume + tailor/save/ # POST — persists tailored resume draft + jobs/extract/ # POST — AI field extraction from URL/text + jobs/import/ # POST — saves a custom job listing + webhooks/clerk/ # POST — Clerk webhook (user lifecycle) + onboarding/ # Onboarding wizard for new users + page.tsx # Landing page components/ - dashboard/ # StatsRow - jobs/ # JobCard, JobFeed, ScoreBadge, JobDetailActions - tailor/ # TailorPanel, GeneratePane, ResumePDFDocument, PDFPreview - pipeline/ # PipelineTable, ApplicationDrawer, StatusSelect - layout/ # AppNav (collapsible sidebar + mobile tabs) - landing/ # HeroDemoPreview, FeatureRow, LandingNav + dashboard/ # FeedToolbar, ProfileSwitcher + jobs/ # JobCard, JobFeed, ScoreBadge, ImportJobModal + tailor/ # TailorPanel, GeneratePane, ResumePDFDocument, PDFPreview + pipeline/ # PipelineTable, KanbanBoard, ApplicationDrawer, StatusSelect + settings/ # SettingsClient, UsageSection, FeedbackForm + layout/ # AppNav (collapsible sidebar + mobile tabs) + landing/ # HeroDemoPreview, FeatureRow, LandingNav + onboarding/ # OnboardingWizard lib/ - prisma.ts # Prisma client singleton - openrouter.ts # OpenRouter client + model constants - match.ts # jobMatchesProfile() — in-process pool filtering - normalize.ts # Source raw data → JobPool schema - scrapers/ # greenhouse.ts, lever.ts, ashby.ts + prisma.ts # Prisma client singleton + openrouter.ts # OpenRouter client (server-only) + models.ts # Model constants + getModels() helper (client-safe) + match-sql.ts # SQL-based pool matching + rematch + normalize.ts # Source raw data → JobPool schema + validations.ts # Zod schemas for all API request bodies + store.ts # Zustand store (dashboard state) + scrapers/ # greenhouse, lever, ashby, usajobs, adzuna, arbeitnow config/ - app.ts # APP_CONFIG — app name lives here only - companies.ts # Company slugs for each scraper source + app.ts # APP_CONFIG — app name lives here only + companies.ts # Company lists + search configs for all scrapers prisma/ - schema.prisma # Source of truth for DB schema - seed.ts # Realistic mock data for development + schema.prisma # Source of truth for DB schema +tests/ + *.spec.ts # Playwright E2E tests + unit/ # Vitest unit tests + global-setup.ts # Playwright global setup — seeds test data +.github/ + workflows/ci.yml # CI: typecheck → lint → unit → Playwright ``` --- +## CI/CD + +Every push to `main` and every pull request runs the full CI pipeline via GitHub Actions: + +| Step | What it does | +|---|---| +| **Type check** | `pnpm tsc --noEmit` | +| **Lint** | ESLint | +| **Unit tests** | Vitest + React Testing Library | +| **E2E tests** | Playwright (runs against a Neon dev branch database) | + +Playwright tests run after all other checks pass. Test reports are uploaded as artifacts and retained for 14 days. Vercel auto-deploys `main` to production and PR branches to preview URLs. + +--- + ## Contributing Contributions are welcome. This project uses the **Business Source License** (see below), so you can run it locally and submit pull requests freely — but you may not deploy it commercially without permission. @@ -188,7 +218,7 @@ If you want to contribute: 1. **Open an issue first** for anything non-trivial. Describe what you want to change and why. This avoids wasted effort on PRs that won't be merged. 2. **Fork, branch, and PR.** Branch names follow `type/kebab-description` (e.g. `feature/email-notifications`, `fix/feed-filter`). 3. **One concern per PR.** Don't bundle a bug fix with a refactor. -4. **Pass the type check.** Run `pnpm tsc --noEmit` before submitting. +4. **Pass CI.** Run `pnpm tsc --noEmit` and `pnpm test:unit` before submitting. Areas where contributions are especially useful: @@ -201,11 +231,14 @@ Areas where contributions are especially useful: ## Roadmap -- [ ] Clerk webhook — creates `User` records on sign-up (required for multi-user) -- [ ] Onboarding polish +- [x] Clerk webhook — user lifecycle events (created, updated, deleted) +- [x] Onboarding wizard for new users +- [x] Multi-profile support with independent feeds and pipelines +- [x] USAJobs, Adzuna, and Arbeitnow scrapers (6 sources total) +- [x] Kanban board view for pipeline +- [x] User-configurable AI model overrides - [ ] Email notifications for new high-score matches -- [ ] Expanded company lists for all three scrapers -- [ ] LinkedIn scraper via Apify +- [ ] LinkedIn scraper - [ ] SaaS billing (Stripe) --- diff --git a/prisma/migrations/20260409131657_add_style_guide_to_profile/migration.sql b/prisma/migrations/20260409131657_add_style_guide_to_profile/migration.sql new file mode 100644 index 0000000..8b6c2de --- /dev/null +++ b/prisma/migrations/20260409131657_add_style_guide_to_profile/migration.sql @@ -0,0 +1,8 @@ +-- DropIndex +DROP INDEX "idx_job_pool_location_trgm"; + +-- DropIndex +DROP INDEX "idx_job_pool_title_trgm"; + +-- AlterTable +ALTER TABLE "profiles" ADD COLUMN "styleGuide" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c2b7144..a2cf4dd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -89,6 +89,7 @@ model Profile { bannedPhrases String[] // Never use these phrases verifiedMetrics String[] // Use exactly these figures/achievements neverClaim String[] // Never imply experience with these + styleGuide String? // Free-text voice/tone preferences for AI writing // AI model overrides (null = use default from openrouter.ts) customTailorModel String? diff --git a/src/app/(admin)/actions.ts b/src/app/(admin)/actions.ts index 5e050aa..38205f0 100644 --- a/src/app/(admin)/actions.ts +++ b/src/app/(admin)/actions.ts @@ -162,6 +162,7 @@ export async function adminCopyProfileToAdmin( bannedPhrases: sourceProfile.bannedPhrases, verifiedMetrics: sourceProfile.verifiedMetrics, neverClaim: sourceProfile.neverClaim, + styleGuide: sourceProfile.styleGuide, // AI model overrides customTailorModel: sourceProfile.customTailorModel, customAnalyzeModel: sourceProfile.customAnalyzeModel, diff --git a/src/app/(dashboard)/settings/actions.ts b/src/app/(dashboard)/settings/actions.ts index 33b0745..5fb6a3a 100644 --- a/src/app/(dashboard)/settings/actions.ts +++ b/src/app/(dashboard)/settings/actions.ts @@ -185,6 +185,7 @@ export async function createProfile(data: unknown): Promise<{ profileId: string bannedPhrases: activeProfile.bannedPhrases, verifiedMetrics: activeProfile.verifiedMetrics, neverClaim: activeProfile.neverClaim, + styleGuide: activeProfile.styleGuide, currency: activeProfile.currency, }), }, diff --git a/src/app/api/tailor/route.ts b/src/app/api/tailor/route.ts index 485df77..1c35bf2 100644 --- a/src/app/api/tailor/route.ts +++ b/src/app/api/tailor/route.ts @@ -68,6 +68,7 @@ export async function POST(req: Request) { bannedPhrases: true, verifiedMetrics: true, neverClaim: true, + styleGuide: true, customTailorModel: true, customAnalyzeModel: true, customExtractModel: true, @@ -135,12 +136,20 @@ export async function POST(req: Request) { const contentSource = profile.curriculumVitae ?? profile.masterResume!; const formatTemplate = profile.masterResume; + const hasArrayRules = + (profile.protectedPhrases?.length ?? 0) > 0 || + (profile.bannedPhrases?.length ?? 0) > 0 || + (profile.verifiedMetrics?.length ?? 0) > 0 || + (profile.neverClaim?.length ?? 0) > 0; + + const hasWritingRules = hasArrayRules || !!profile.styleGuide; + const systemPrompt = `## SYSTEM -You are a professional resume writer with a decade of experience hiring -${profile.targetRoles.join(", ")} across verticals. +You are a professional resume writer with a decade of experience hiring +${profile.targetRoles.join(", ")} across verticals. You have been given: 1. A candidate's comprehensive CV -2. Their preferred resume format — a structural template only; +2. Their preferred resume format — a structural template only; treat the summary and bullets in it as placeholders, not model copy 3. A specific job description @@ -149,33 +158,33 @@ Your task is to produce a focused, targeted resume for this role. ## PROCESS Step 1 — Understand the candidate -Read the full CV. Identify their strongest, most verifiable proof points -(metrics, named technologies, real outcomes). Note what they have actually +Read the full CV. Identify their strongest, most verifiable proof points +(metrics, named technologies, real outcomes). Note what they have actually built vs. what they have only configured or used. Step 2 — Understand the role -Identify the top 3–5 things this employer actually needs. Distinguish +Identify the top 3–5 things this employer actually needs. Distinguish must-haves from nice-to-haves. Step 3 — Match honestly -Select experience and skills that genuinely satisfy what the employer needs. -Mirror the job description's language ONLY where the description accurately -reflects what the candidate did. Do not relabel simpler work with -more impressive JD terminology to make it appear to match. A hiring manager -who interviews this candidate will probe every bullet — if the framing +Select experience and skills that genuinely satisfy what the employer needs. +Mirror the job description's language ONLY where the description accurately +reflects what the candidate did. Do not relabel simpler work with +more impressive JD terminology to make it appear to match. A hiring manager +who interviews this candidate will probe every bullet — if the framing doesn't survive a follow-up question, cut it. Step 4 — Write the summary -Write a new summary for this specific role. Do not reuse the template -summary. It should be 2–3 sentences: what the candidate does, their -most relevant proof point for this role, and one honest differentiator. -No filler phrases ("unique blend", "expert in bridging"). Lead with +Write a new summary for this specific role. Do not reuse the template +summary. It should be 2–3 sentences: what the candidate does, their +most relevant proof point for this role, and one honest differentiator. +No filler phrases ("unique blend", "expert in bridging"). Lead with the most impressive thing that is directly relevant to this job. Never use an "—". Step 5 — Write the bullets - Every bullet must be results-oriented: action → method → outcome -- Include specific numbers wherever the CV provides them; do not omit +- Include specific numbers wherever the CV provides them; do not omit metrics in favor of vaguer language - Do not invent, inflate, or reframe experience the candidate does not have - Order bullets by relevance to this role, not chronology within a role @@ -184,10 +193,10 @@ Step 5 — Write the bullets Step 6 — Format and output - Use the template's structure and layout -- Bold sparingly: only the single most important phrase per bullet, - and only where it adds scannability for a human reader. +- Bold sparingly: only the single most important phrase per bullet, + and only where it adds scannability for a human reader. If everything is bold, nothing is. -- Return only the resume markdown — no commentary, no preamble, +- Return only the resume markdown — no commentary, no preamble, no explanation - Ensure all links are properly formatted - Place contact details at the very top @@ -196,13 +205,14 @@ Step 6 — Format and output Step 7 — Final Review Think about what you have made and what could be improved. If the current version is an 8/10, identify what would make it a 9.5/10 and implement that adjustment, but never use an "—" em-dash.${ - (profile.protectedPhrases?.length ?? 0) > 0 || - (profile.bannedPhrases?.length ?? 0) > 0 || - (profile.verifiedMetrics?.length ?? 0) > 0 || - (profile.neverClaim?.length ?? 0) > 0 - ? `\n\n## CANDIDATE'S WRITING RULES (non-negotiable)\n${ + hasWritingRules + ? `\n\n## CANDIDATE'S WRITING RULES (non-negotiable)${ + profile.styleGuide + ? `\n\nStyle guide — follow these voice and tone preferences throughout the resume:\n${profile.styleGuide}` + : "" + }${ (profile.protectedPhrases?.length ?? 0) > 0 - ? `\nProtected phrases — use verbatim, never paraphrase:\n${profile.protectedPhrases!.map((p) => `- ${p}`).join("\n")}` + ? `\n\nProtected phrases — use verbatim, never paraphrase:\n${profile.protectedPhrases!.map((p) => `- ${p}`).join("\n")}` : "" }${ (profile.bannedPhrases?.length ?? 0) > 0 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 990414a..913cec7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import Script from "next/script"; import { ClerkProvider } from "@clerk/nextjs"; import { ThemeProvider } from "@/components/providers/ThemeProvider"; import { Analytics } from "@vercel/analytics/next"; @@ -28,6 +29,12 @@ export default function RootLayout({ }>) { return ( +
diff --git a/src/components/settings/SettingsClient.tsx b/src/components/settings/SettingsClient.tsx index 93525cc..a437e64 100644 --- a/src/components/settings/SettingsClient.tsx +++ b/src/components/settings/SettingsClient.tsx @@ -589,6 +589,7 @@ function ResumeWritingRulesSection({ profile }: { profile: Profile }) { bannedPhrases: toLines(profile.bannedPhrases), verifiedMetrics: toLines(profile.verifiedMetrics), neverClaim: toLines(profile.neverClaim), + styleGuide: profile.styleGuide ?? "", }); const [isPending, startTransition] = useTransition(); const [saved, setSaved] = useState(false); @@ -612,6 +613,7 @@ function ResumeWritingRulesSection({ profile }: { profile: Profile }) { bannedPhrases: fromLines(fields.bannedPhrases), verifiedMetrics: fromLines(fields.verifiedMetrics), neverClaim: fromLines(fields.neverClaim), + styleGuide: fields.styleGuide.trim() || undefined, }); setSaved(true); } catch { @@ -674,6 +676,17 @@ function ResumeWritingRulesSection({ profile }: { profile: Profile }) { />{error}
}