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.
Try the live demoView source
Relay admin pipeline overview with stats and score distribution
The pipeline at a glance — every lead scored, routed, and reviewable.

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.

Lead submits form→POST /api/leads→reference code (immediate)→extract via Groq→score via rubric→route→receipt + sales notify→admin dashboard
Relay admin leads table with scores, intents, and statuses
Every lead, filterable and sortable.

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
Score breakdown panel showing sub-scores and weights
Sub-scores and weights, rendered straight from the DB. No client-side recomputation.

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
Full lead detail page with AI summary, extraction, routing, and timeline
Full lead detail — AI summary, extraction, routing, timeline.

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.

Built by Darvin Raj.

Relay is a fictional CRM. LeadFlow is a portfolio piece — no real customer data.