Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
135 changes: 84 additions & 51 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
[![CI](https://github.com/mojoro/shortlist/actions/workflows/ci.yml/badge.svg)](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.

Expand All @@ -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

<!-- Add screenshots here once hosted -->
<!-- ![Job feed](docs/screenshots/feed.png) -->
<!-- ![Resume tailor](docs/screenshots/tailor.png) -->
<!-- ![Pipeline tracker](docs/screenshots/pipeline.png) -->
<!-- ![Landing page](docs/screenshots/landing.png) -->
- **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.

---

Expand All @@ -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` |
Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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` |

Expand All @@ -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.
Expand All @@ -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:

Expand All @@ -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)

---
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
1 change: 1 addition & 0 deletions src/app/(admin)/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/app/(dashboard)/settings/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
},
Expand Down
60 changes: 35 additions & 25 deletions src/app/api/tailor/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export async function POST(req: Request) {
bannedPhrases: true,
verifiedMetrics: true,
neverClaim: true,
styleGuide: true,
customTailorModel: true,
customAnalyzeModel: true,
customExtractModel: true,
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -28,6 +29,12 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<Script
defer
src="https://umami-ek8u.vercel.app/script.js"
data-website-id="9e47535a-55be-4827-aabe-cba9862b3f2d"
strategy="afterInteractive"
/>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
Expand Down
Loading
Loading