Case Study
LeadFlow — inbound that routes itself
LeadFlow qualifies, scores, and routes inbound B2B leads through a public form, a background AI pipeline, and an admin dashboard. The non-trivial part is the scoring contract: the model classifies, a fixed rubric scores, and every number in the UI is auditable back to a rule.
- Role
- Solo — design, engineering, documentation.
- Stack
- Next.js 15 · Supabase · Groq / Gemini · Resend · Vercel.
- Timeline
- Built over 2 days.

01 — The problem
Small sales teams drown in inbound they can't triage
A five-person B2B sales team gets a few dozen inbound messages a week: demo requests next to job seekers next to SEO cold pitches. Reading all of them costs hours; ignoring them costs pipeline. What the team needs is not a smarter inbox — it is a front door that decides, in seconds, which messages deserve a human.
The obvious answer — "AI ranks your leads 0-100" — is one I didn't trust enough to build. A model emitting a bare number is unauditable: when a rep asks why this lead is a 78 and that one a 72, "the model felt like it" ends the conversation. Nobody stakes quota on a number nobody can explain, so the tool gets ignored and the spreadsheet comes back.
What v1 set out to prove is narrower: the AI classifies, the rubric scores, and the number is auditable. The model outputs discrete categories it can actually observe in the message; deterministic code turns those categories into points. Every score in the dashboard decomposes into four sub-scores with published weights — and that decomposition is the product.
02 — How it works
One form fill, eight steps, three seconds
The request path does the minimum — validate, insert, return a reference code — and everything expensive happens after the response is already on its way back. The admin dashboard reads the same rows the pipeline writes; there is no separate read model.

03 — Technical decisions
Six calls I'd defend in an interview
AI classifies, code scores
The model never outputs a number. It returns discrete categories — company_size, industry, intent, budget_signal — and a pure TypeScript function maps them to points with fixed weights (company 30, industry 25, intent 25, budget 20). The score is deterministic given the extraction: a 92 is traceable to a specific model output plus a specific rule, which is the whole answer to the black-box objection.
scoreLead(extraction) // same input, same score, every time

Spam is a hard override, not a point deduction
The rubric bottomed out at 8/100 for obvious spam — solo, unknown industry, no budget signal. An 8 reads as "some fit," which is wrong; spam has no fit. I added an early return: intent === spam scores 0 across the board. Routing treats 0 as definitive. Three lines, large narrative payoff.
if (e.intent === 'spam') return all zeros // 0/100 by construction
Fire-and-forget enrichment via waitUntil
The POST returns the reference code before the AI runs. The visitor sees success in ~370ms; enrichment finishes ~3s later in the same serverless invocation. First version used a floating promise (`void runEnrichment()`) — which silently died on Vercel because the function freezes as soon as the response is sent. Fixed with waitUntil from @vercel/functions, which extends the function lifetime until the promise settles. Would move to a proper job queue at real volume.
import { waitUntil } from '@vercel/functions';
waitUntil(runEnrichment(id).catch(console.error));
return NextResponse.json({ ok: true, reference_code });HTML injection in email templates
Security review caught this one before it shipped: the lead's name and company went unescaped into the receipt HTML, and the AI summary into the sales notification. An attacker-controlled string rendered in the sales inbox inherits real trust. I added lib/email/escape-html.ts — four replacements applied to every interpolated value — and stripped newlines from the subject line.
escapeHtml(name) // & < > " ' — text part stays raw
RSC function-prop boundary
The leads table passed a sortHref builder from a server component into a client component. TypeScript was happy, next build was happy — it only crashes at request time in production with "Functions cannot be passed directly to Client Components." I moved the URL builder into the client component, which already held everything it needed. The class of bug that only surfaces on first deploy.
props: leads, sort, dir, status // serializable only
Schema/type drift prevention
Three artifacts must agree on every enum literal: the Extraction TS type, the zod schema fed to generateObject, and the SQL CHECK constraints. The first two are pinned together by a compile-time IsExact guard, so changing one side breaks the build until the other follows. The SQL side is covered by a header note and a seed script that asserts exact totals — 20 hand-computed scores that fail loudly on rubric drift.
IsExact<z.infer<schema>, Extraction> // drift = build error

04 — What I'd do differently
Three honest limitations
- The rate limit is a single-instance in-memory Map. Fine for a demo, resets on cold start. Upstash Redis + sliding window is the production shape.
- Receipt delivery to non-owner emails requires a verified Resend domain. I would spend the $10/yr on a real domain if this were a paid product instead of a portfolio piece.
- Admin auth is a shared password, not a user system. Fine for a portfolio demo, wrong for multi-tenant.
05 — Under the hood
The receipts
- ~5,200 lines
- across ~80 tracked files
- 13 commits
- layers 1–6 plus prod fixes
- 20 seeded leads
- score plan 5/5/5/3/2, verified live
- All green
- tsc, build, seed — zero paid tools
Groq gpt-oss-120b primary, Gemini 3.1-flash-lite fallback. Supabase, Groq, Gemini, Vercel, Resend, and GitHub all on free tiers — the only bill for this project is $0.