Skip to content
jabluetoothPublic

About

AI-powered career management dashboard that matches jobs to your resume, tailors it per posting, researches companies, preps interviews, and tracks every application via an n8n automation backend.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

81 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Match

An AI-powered career management dashboard. Find matching roles, tailor your resume to each one, research the company, prep for the interview, and track every application - all in one place.

Live demo →

Next.js React TypeScript Prisma PostgreSQL Clerk n8n Vercel


Match demo


Why this exists

Job hunting is a manual, repetitive workflow: scrape boards, score fit, rewrite the resume to match each posting, research the company, prep for interviews, track every reply. Match automates the parts a computer should do and surfaces the parts you need to see.

The interesting engineering problem isn't the dashboard - it's the orchestration: long-running AI tasks (resume tailoring, company research, interview prep) shouldn't block the UI, shouldn't lose state on a page refresh, and shouldn't silently fail when the LLM returns malformed JSON or a third-party API rate-limits you.

The solution is a thin Next.js app talking to an n8n workflow backend over signed webhooks, with the heavy lifting (scraping, AI calls, PDF rendering, email) running asynchronously in n8n. The app polls Postgres for completion and renders results when they land.


Architecture

                ┌─────────────────────────────────────────────┐
                │                  Browser                    │
                │  Next.js 16 (App Router) · React 19 · Clerk │
                └──────────────┬───────────────────────┬──────┘
                               │                       │
                  Authenticated│API calls    Polls for │
                  (Clerk JWT)  │             updates   │
                               ▼                       │
                ┌─────────────────────────────────────────────┐
                │           Next.js API Routes                │
                │   /api/match/jobs · /api/tailor/resume      │
                │   /api/research/company · /api/resume/*     │
                │   /api/internal/resume/[userId]  ──┐        │
                └──────────────┬───────────────┬─────┘        │
                               │               │              │
                Signed webhooks│        Direct │ DB reads     │
              (x-webhook-secret│       (Prisma)│              │
                               ▼               ▼              │
                ┌─────────────────────────┐   ┌───────────────┴──┐
                │   n8n workflow engine   │   │   PostgreSQL     │
                │   tailor-resume         │◄──┤    (Neon)        │
                │   company-research      │   │                  │
                │   match-job             │──►│   user_profiles  │
                │   interview-prep        │   │   jobs, matches  │
                │   application-tracker   │   │   tailored_*     │
                │   followup-response     │   │   research, prep │
                └──────┬───────┬──────────┘   └──────────────────┘
                       │       │
                  Groq │       │ JSearch · NewsData.io · PDFShift
                  (LLM)│       │ RSS feeds · LinkedIn scrape
                       ▼       ▼
                  ┌────────────────────┐
                  │   External APIs    │
                  └────────────────────┘

Request lifecycle for the AI features:

  1. User clicks "Tailor Resume" → POST /api/tailor/resume signs the payload with N8N_WEBHOOK_SECRET and POSTs to the n8n webhook.
  2. n8n calls back into /api/internal/resume/[userId] (same shared secret) to fetch the user's original resume bytes from Postgres.
  3. n8n parses the PDF, feeds it to Groq with a "preserve original layout, rewrite content" prompt, and writes the HTML into tailored_resumes.
  4. The frontend polls /api/tailor/resume/[jobId]/status every 3 s for up to 5 min. When the row appears, it enables the "Download Resume" button.
  5. Download → PDFShift renders the stored HTML to PDF on demand.

Features

Area Highlights
Dashboard Four readings (matches waiting, active, interviews, offers), the pipeline rail (Applied → Screened → Interview → Offer) with per-stage conversion, a setup checklist that folds away once done, recent applications and an activity ledger.
Job matches AI-scored against the user's profile (Groq). Each card shows the fit score as mono numerals over a tick meter, the model's reasoning, and skills you have or lack. URL-state search with segmented work-type and sort controls, paged grid.
Resume tailoring LLM rewrites content while preserving the original layout. Original PDF stored as BYTEA; tailored copy stored as HTML and rendered to PDF on download.
Company research Scrapes company site + recent news (NewsData.io), produces a brief: overview, mission, why-hiring, talking points, smart questions, red flags.
Interview prep Per-application brief: role analysis, behavioural + technical questions, STAR answer scaffolds, salary guidance. Optional LinkedIn scrape of the interviewer. HTML rendered in a sandbox="" iframe for safety.
Application tracking Pipeline rail with per-stage conversion, underlined status tabs, a per-row stage indicator, a scheduled-interview dialog, follow-up logging with server-side response-rate recompute.
Notifications Real signals only: interviews within 7 days, follow-ups awaiting reply, fresh matches.
Settings Drag-and-drop resume upload (PDF/DOC/DOCX, 5 MB cap), skills/titles/industries as comma-list chips, salary + work-type preferences.

Engineering highlights

Things I'd point a reviewer to:

  • Pre-flight diagnostics, not stuck spinners. POST /api/match/jobs runs an eligibility query before triggering n8n. When the daily scrape has produced zero new jobs, the user sees "You're caught up - every job in the last 7 days has been scored" instead of staring at a spinner for 2 minutes. The button now distinguishes four failure modes: no_jobs_in_db, no_recent_scrape, all_jobs_already_matched, n8n_unreachable. See app/api/match/jobs/route.ts.
  • Polling that tracks the right signal. The "Find New Matches" client originally watched only count of pending matches. If the AI rejected every candidate as <70, the count never moved and the user thought it was stuck. The current poller also watches total and lastMatchAt, so it correctly reports "Scoring finished - no strong matches". See components/find-matches-button.tsx.
  • Signed server-to-server callback. n8n needs the user's original resume to preserve formatting during tailoring. Resume bytes live in Postgres BYTEA (not a public bucket), and n8n reads them via GET /api/internal/resume/[userId] authenticated by a shared x-webhook-secret header - same secret used to sign outbound webhook calls. See app/api/internal/resume/[userId]/route.ts and lib/n8n-client.ts.
  • Resume storage in Postgres, not S3. Files cap at 5 MB and live on the same row as the rest of the user profile. No public URL, no signed-URL plumbing, no extra dependency, and Vercel's read-only /var/task/public filesystem doesn't matter. See prisma/migrations/resume_blob_columns.sql.
  • Stacking-context-aware loaders. The fullscreen loader uses position: fixed, but a transformed ancestor in the paginated job-cards container created a new containing block and the loader was rendering inside the page slide. Fixed by rendering through React.createPortal(content, document.body). See components/ui/brand-loader.tsx.
  • Sandboxed third-party HTML. n8n workflows emit interview-prep / research HTML that's user-influenced. It's rendered inside <iframe sandbox=""> with a fresh document, so any reflected injection can't reach the parent DOM. See components/prep-html-viewer.tsx.
  • Server-derived user_id everywhere. Every API route derives userId from the Clerk session - the client never sends it, even when it's in the form body. Resource-bound routes (/api/track/application, etc.) additionally call verifyOwnership() before mutating. See lib/auth.ts and lib/validation.ts.
  • Defensive AI response handling. LLM JSON parsing accepts both OpenAI-shape (choices[0].message.content) and Gemini-shape (candidates[0].content.parts[0].text), strips markdown fences, and on failure writes a placeholder row with confidence_score = 10 + the raw response - so the UI degrades visibly instead of breaking. See the company-research and tailor-resume flows in Match.json.
  • Dev vs prod webhook routing. lib/n8n-client.ts automatically targets /webhook-test/... in development (lets you click "Listen for test event" in the n8n editor) and /webhook/... in production. Overridable via N8N_FORCE_TEST_WEBHOOKS for the edge cases.

Tech stack

Layer Choice
Framework Next.js 16 (App Router, RSC + server actions)
UI React 19, Tailwind CSS, Radix primitives, lucide icons
State Zustand (toast store), React useState everywhere else
Database PostgreSQL 16 via Prisma 7 (hosted on Neon)
Auth Clerk (session cookies, middleware-protected routes)
Workflows n8n - 6 webhooks orchestrating scraping, LLM calls, email
LLM Groq (Llama 3.1 8B for scoring + tailoring + research)
Job sources RapidAPI JSearch, Remotive RSS, We Work Remotely RSS
News NewsData.io
PDFs PDFShift (server-side HTML → PDF)
Email Gmail (via n8n's built-in Gmail node)
Charts Recharts
Hosting Vercel (app), Neon (Postgres)

Local setup

git clone https://github.com/<you>/match.git
cd match
npm install
cp .env.example .env       # fill in the values (see Environment variables)
npm run db:push            # creates tables from prisma/schema.prisma
npm run dev                # http://localhost:3000

Then in n8n: Workflows → Import from File → pick Match.json from the repo root. Activate the workflow.

Sign in with Clerk, drop a PDF resume in Settings, then click Find New Matches on the Jobs page.

Prerequisites

  • Node.js 18+
  • A PostgreSQL database (Neon recommended)
  • A Clerk application
  • An n8n instance - self-hosted or n8n Cloud free tier is enough
  • Optional: a PDFShift API key (only needed for tailored-resume PDF downloads)

Environment variables

Variable Required Description
DATABASE_URL ✅ PostgreSQL connection string. Use the pooled URL on Vercel (Neon: hostname ends with -pooler).
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY ✅ Clerk publishable key.
CLERK_SECRET_KEY ✅ Clerk secret key.
N8N_BASE_URL ✅ Root URL of the n8n instance, no trailing slash.
N8N_WEBHOOK_SECRET ✅ in prod Shared secret sent as x-webhook-secret on every outbound n8n call and required on the /api/internal/* callbacks. Configure the same value on each n8n Webhook node (Authentication: Header Auth).
NEXT_PUBLIC_APP_URL ✅ in prod Public origin of this app - used to build the callback URL n8n hits to fetch original-resume bytes. e.g. https://match.example.com.
PDFSHIFT_API_KEY ⚪ Needed for /api/tailor/resume/[jobId]/download to render tailored resumes to PDF.
N8N_API_KEY ⚪ Only required for n8n execution-status polling.
N8N_FORCE_TEST_WEBHOOKS ⚪ true to force /webhook-test/ URLs in prod; false to force /webhook/ in dev. Default is automatic (NODE_ENV-based).

Clerk middleware (middleware.ts) protects every route except /sign-in, /sign-up, /api/webhooks/*, and /api/internal/* (the last is authed via N8N_WEBHOOK_SECRET). Unauthenticated requests to anything else get a 401.

Resume storage migration

Base resumes are stored as BYTEA on user_profiles (base_resume_data + metadata columns). Before deploying, run prisma/migrations/resume_blob_columns.sql once against your Postgres - the migration is idempotent.


Design

Match has its own identity, separate from its sibling projects (Relay, Insight, Mimo). The rules live in .skills/design/skill.md §4.4; in short:

  • Dark only, amber on near-black. --bg #0b0b0d, one brand colour (--accent #d9a441). Tokens are bare RGB channels in app/globals.css, so every Tailwind colour takes an /opacity modifier.
  • Violet means interview. --interview #9c97d6 appears only on interview UI: the interview stage, the calendar date block, notification icons, status pills.
  • Type: Instrument Serif for headlines (one italic amber word per title), Inter for UI, JetBrains Mono for every number, label and path.
  • Signature pieces in components/system/: the match% wordmark, the fit score (numerals over a 20-tick meter), the pipeline rail and its compact four-tick form on every application row, and the ledger-style section label (01 —— label).
  • Motion: one easing ([0.16, 1, 0.3, 1]), masked line reveals for headlines, count-up numerals, hairlines that draw in, a scroll-driven rail on the landing page. Everything honours prefers-reduced-motion through MotionConfig.
  • App shell: a Linear-style left sidebar with g + letter shortcuts (g d dashboard, g j jobs, g a applications, g i interviews, g f follow-ups, g s settings), and a glass topbar, the only blurred surface in the app.
  • Honesty: the landing page's match card is labelled illustrative, and every engineering claim links to the file it describes.

Project layout

app/
├── api/                       # all routes auth'd via Clerk except /api/internal/*
│   ├── followup/response/     # log replies, recompute response rate
│   ├── interview-prep/        # trigger n8n, serve generated PDFs
│   ├── internal/resume/[id]/  # server-to-server: n8n fetches user resume bytes
│   ├── interviews/            # list interviews for the user
│   ├── match/jobs/            # trigger matching, count + diagnostics for the button
│   ├── profile/update/        # save profile (zod-validated)
│   ├── research/company/      # trigger + read company research
│   ├── resume/                # upload / delete / serve inline
│   ├── tailor/resume/         # trigger tailoring, poll status, PDF download
│   └── track/application/     # create / update_status / schedule_interview
├── applications/              # pipeline + status filter + search
├── followups/                 # follow-up list with response tracking
├── interview-prep/[id]/       # sandboxed iframe viewer
├── interviews/                # upcoming + past, schedule modal
├── jobs/                      # match cards, search, filters
├── research/[jobId]/          # company research report viewer
├── settings/                  # profile, resume upload, preferences
├── layout.tsx                 # Sidebar + Header + Toaster
├── loading.tsx                # pipeline-rail route loader
└── page.tsx                   # dashboard

components/
├── system/                    # design primitives: wordmark, motion, fit score, stage rail, page head, status pill
├── app-shell/                 # sidebar (+ g-key shortcuts), mobile drawer, breadcrumb
├── dashboard/                 # readings, setup checklist, recent applications, activity
├── marketing/                 # landing page: header, hero ticket, loop, feature rows, footer
├── ui/                        # toast, brand-loader
├── job-match-card.tsx         # one match card - tailor / research / apply
├── job-matches-paged.tsx      # paged carousel
├── find-matches-button.tsx    # "Find New Matches" trigger + diagnostic toasts
├── workflow-loader.tsx        # fullscreen overlay during n8n calls (portal'd)
├── interview-prep-button.tsx
├── settings-form.tsx
└── …

lib/
├── auth.ts                    # requireAuth / requireUserWithSync / verifyOwnership
├── n8n-client.ts              # typed webhook wrapper with timeouts + signing
├── notifications.ts           # server fn for the notifications popover
├── prisma.ts                  # Prisma client singleton
├── status.ts                  # one map of status → label, pipeline stage, tone
├── utils.ts
└── validation.ts              # zod schemas + sanitizeString

prisma/
├── schema.prisma              # 14 models
└── migrations/
    ├── clerk_auth_migration.sql
    └── resume_blob_columns.sql

Match.json                     # exportable n8n workflow (6 webhooks + scrape cron)

Deployment (Vercel)

  1. Push to GitHub.
  2. New Project on Vercel → import the repo. Auto-detects Next.js.
  3. Add the env vars above. DATABASE_URL = Neon pooled connection string.
  4. Click Deploy. Build runs prisma generate && next build (configured in vercel.json).
  5. In Neon's SQL editor, run prisma/migrations/resume_blob_columns.sql once.
  6. Configure Clerk's allowed origins to include your Vercel domain.
  7. Import Match.json into n8n, set the Match Webhook Secret Header Auth credential to your N8N_WEBHOOK_SECRET, attach it to every Webhook Trigger node, and activate the workflow.

The app is a plain Node + Postgres app, so it'll also run on Railway, Render, Fly.io, or a regular server. Just match the env vars.


Security

  • Auth everywhere. Every API route calls requireAuth() from lib/auth.ts. Resource-bound routes additionally call verifyOwnership() against the row's user_id before mutating. The client never sends user_id.
  • Zod-validated bodies. Every POST body is parsed through a schema in lib/validation.ts. String fields are HTML-escaped before reaching Prisma to neutralise reflected XSS via downstream LLM output.
  • Signed n8n traffic. Every outbound webhook call carries x-webhook-secret. The n8n side enforces this via a Header Auth credential on each Webhook Trigger. The reverse /api/internal/* callback validates the same secret.
  • Sandboxed third-party HTML. Interview-prep and research HTML come from n8n + an LLM and are rendered inside <iframe sandbox=""> with a fresh document.
  • Security headers set globally in next.config.mjs: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy: camera=(), microphone=(), geolocation=().
  • File uploads are Clerk-authed, MIME-whitelisted (PDF / DOC / DOCX), 5 MB capped, with the storage filename derived from the authenticated userId - never the client-supplied filename.

Known gaps (not yet shipped): rate limiting, CSRF tokens on top of Clerk's SameSite=Lax, full Content-Security-Policy, Postgres row-level security (the policies are drafted in prisma/rls-policies.sql but not enabled).


Development

npm run dev            # dev server on :3000
npm run build          # prisma generate + next build
npm run start          # serve the production build locally
npm run db:generate    # regenerate the Prisma client
npm run db:push        # push schema changes to the DB without migrations
npm run db:studio      # Prisma Studio (DB inspector)

Troubleshooting

Symptom Likely cause
Unauthorized on every API call Clerk env vars missing, or the Clerk dashboard's allowed origins don't include your dev/prod URL.
Resume upload errors with ENOENT '/var/task/public' The BYTEA migration hasn't been run on prod yet. Apply prisma/migrations/resume_blob_columns.sql in Neon's SQL editor.
"Find New Matches" returns nothing Either no jobs were scraped today (check SELECT MAX(scraped_at) FROM jobs) or every job has already been scored. The button's toast now spells out which case it is.
n8n calls 404 in dev Default is /webhook-test/.... Click "Listen for test event" on the relevant Webhook node in the n8n editor, or set N8N_FORCE_TEST_WEBHOOKS=false to hit production URLs.
Loader2 import errors after switching branches Turbopack cache wedge. rm -rf .next and restart the dev server.
Pages feel slow in dev npm run dev is intentionally unoptimised. Try npm run build && npm start. Also confirm you're using Neon's pooled connection string.
Tailored resume looks generic The n8n workflow falls back to a template when it can't read the original resume. Check N8N_WEBHOOK_SECRET is set on both sides and the n8n Fetch Original Resume node returns 200.

Changelog

2026-09-24

  • Redesigned every page: a new token system, a left sidebar with keyboard navigation in place of the bottom dock, and a rewritten landing page. See Design.
  • The dashboard's "recent matches" showed scores made up from application status; it now lists recent applications with their real status and stage.
  • Removed the "Add follow-up" button on the follow-ups page, which had no handler.
  • The activity feed names the job for each event (the workflow writes empty metadata) and describes status changes as "Applied → Interview".
  • Pipeline counts now include final_round applications in the Applied, Screened and Interview stages.

2026-07-29

  • Fixed interview-prep pipeline: request/response bodies now match Gemini's generateContent schema instead of an OpenAI-style payload. The prompt-builder node was also generating the wrong JSON shape (a leftover copy-paste from the company-research prompt) - it now asks for the interview-prep fields the parser actually expects.

About the developer

Fil Heinz O. Re La Torre - Automation & AI Solutions Engineer, building integrations and AI-backed workflows that go from idea to production in days.

Portfolio LinkedIn GitHub Gmail

Other projects: ZeroPress · Mimo · Insight · Se7en · see all →

License

MIT - see LICENSE.

About

AI-powered career management dashboard that matches jobs to your resume, tailors it per posting, researches companies, preps interviews, and tracks every application via an n8n automation backend.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages