---
name: build-connect-ai
description: Walk through building the Connect.AI outreach CRM from an empty folder — a Next.js app that finds small companies on Apollo.io, saves them and their founders to Postgres, and (later) sends outreach through Gmail. Works with any Postgres host (Prisma Postgres, Supabase, Neon, local). Use when asked to "build Connect.AI", "build the outreach CRM", or "scaffold the Connect.AI app".
---

# Build Connect.AI

You are building **Connect.AI**, a small single-user CRM for a student forward-deployed engineering
team. It finds small companies on Apollo.io, saves them and their founders to a Postgres database, and
lays the groundwork for Gmail outreach and a daily "who wrote to me?" digest.

Work through the steps in order. Each step ends with a **Checkpoint**. Don't move on until it passes.
When a step needs something only the user has (an API key, a database URL, a choice of host), stop and
ask. Never invent credentials.

## The finished product

| Page | What it does |
|---|---|
| `/` Overview | Four stat tiles (Companies, Contacts, Emails sent, New senders) and the 6 most recently added companies |
| `/find-companies` | Search Apollo by location, keywords, and employee-size chips. Select results and click "Add N to companies" |
| `/companies` | Table of saved companies: name/domain, industry, employees, location, number of contacts, status |
| `/companies/[id]` | Company detail with its contacts, a **Find people** panel (Apollo people search, then "Reveal & save"), and an **Add by hand** form |
| `/contacts/[id]` | Contact detail: email, LinkedIn, status, outreach history |
| `/outreach`, `/inbox-alerts` | "Not built yet" placeholders for Phase 3 and Phase 4 |
| `/settings` | Shows each integration (Apollo, Google OAuth, cron secret) as Configured or Missing |

## Stack

- **Next.js** (latest, App Router, TypeScript, `src/` dir, `@/*` alias) with **Tailwind CSS v4**
- **shadcn/ui**, used lightly. Most styling comes from a small set of hand-written CSS classes (Step 3).
- **Prisma ORM** (v7+) with the `pg` driver adapter, on **any Postgres**
- **Apollo.io** REST API for company and people data
- `sonner` for toasts, `lucide-react` for icons

> **Next.js version warning:** Recent Next.js releases change APIs that older tutorials rely on. For
> example, route `params` is a `Promise` and must be awaited, and `PageProps`/`LayoutProps` are
> generated global types. After installing, read the guides in `node_modules/next/dist/docs/` before
> writing routes, and follow any deprecation notices.

---

## Step 0: Gather what you need from the user

Ask for these up front:

1. **Which Postgres host?** Any of these works. Recommend one if the user has no preference:
   - **Prisma Postgres**, easiest for local dev: `npx prisma dev` starts a local server and prints a URL.
   - **Supabase**: Project → Connect → ORMs → Prisma. There are two URLs: the pooled one (port 6543,
     `?pgbouncer=true`) for the app, and the direct one (port 5432) for migrations.
   - **Neon**: the pooled URL for the app and the unpooled URL for migrations.
   - Local Docker Postgres or any other Postgres also works.
2. **An Apollo.io API key** (apollo.io → Settings → API). It must be on a **paid plan**. On the Free plan
   the search and reveal endpoints reply "not included in your Free plan". Company and people search
   are free. Enriching a company costs 1 credit, and revealing a person's email costs 1 credit.

**Checkpoint:** you have a database connection string (or a plan for getting one) and an Apollo key.

## Step 1: Scaffold the app

1. Run `npx create-next-app@latest . --ts --eslint --tailwind --app --src-dir --import-alias "@/*"`.
2. Delete the starter `src/app/page.tsx` and the stock SVGs in `public/`.
3. Run `npx shadcn@latest init`, then add these components:
   `npx shadcn@latest add checkbox sonner badge button input label table`.
4. Install the rest: `npm i @prisma/client @prisma/adapter-pg pg` and
   `npm i -D prisma dotenv @types/pg`.
5. Put the logo at `public/connectai-logo.png`. Ask the user for it; any square PNG works as a stand-in.

**Checkpoint:** `npm run dev` starts, and the Next docs exist in `node_modules/next/dist/docs/`.

## Step 2: Database and schema

1. Run `npx prisma init`. Then configure Prisma 7 the modern way:
   - In `prisma/schema.prisma`, use the `prisma-client` generator with output `../src/generated/prisma`,
     and leave the datasource block without a `url`.
   - Create `prisma.config.ts` with `import "dotenv/config"` and `defineConfig({ schema, migrations: { path },
     datasource: { url } })`.
   - **Pooled hosts** (Supabase, Neon): point the config's url at the *direct* URL,
     `process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL`, because migrations can't run
     through a pooler. The app itself uses the pooled `DATABASE_URL`.
   - Add `/src/generated/prisma` to `.gitignore`.
2. Use this data model. The status fields are plain strings, so the app can add new states without a
   migration.

```prisma
model Company {
  id            String    @id @default(cuid())
  apolloOrgId   String?   @unique
  name          String
  domain        String?   @unique
  industry      String?
  employeeCount Int?
  city          String?
  state         String?
  country       String?
  linkedinUrl   String?
  websiteUrl    String?
  status        String    @default("new") // new | researching | contacted | responded | not_interested
  notes         String?
  contacts      Contact[]
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
}

model Contact {
  id             String              @id @default(cuid())
  companyId      String?
  company        Company?            @relation(fields: [companyId], references: [id])
  apolloPersonId String?             @unique
  firstName      String?
  lastName       String?
  email          String              @unique
  title          String?
  linkedinUrl    String?
  phone          String?
  source         String              @default("apollo") // apollo | manual | inbound
  status         String              @default("new")    // new | emailed | replied | not_interested
  outreachEmails OutreachEmail[]
  inboundEvents  InboundEmailEvent[]
  createdAt      DateTime            @default(now())
  updatedAt      DateTime            @updatedAt
  @@index([companyId])
}

model OutreachEmail {
  id             String   @id @default(cuid())
  contactId      String
  contact        Contact  @relation(fields: [contactId], references: [id])
  gmailMessageId String?  @unique
  gmailThreadId  String?
  subject        String
  bodyText       String
  status         String   @default("sent") // sent | failed
  errorMessage   String?
  sentAt         DateTime @default(now())
  @@index([contactId])
}

model InboundEmailEvent {
  id                 String    @id @default(cuid())
  gmailMessageId     String    @unique
  fromEmail          String
  fromName           String?
  subject            String?
  receivedAt         DateTime
  matchedContactId   String?
  matchedContact     Contact?  @relation(fields: [matchedContactId], references: [id])
  includedInDigestAt DateTime?
  createdAt          DateTime  @default(now())
  @@index([matchedContactId])
}

model GmailIntegration {           // single row, id = "singleton"
  id                    String    @id @default("singleton")
  gmailEmailAddress     String?
  refreshTokenEncrypted String?
  accessTokenCache      String?
  accessTokenExpiresAt  DateTime?
  scopes                String?
  lastHistoryId         String?
  connectedAt           DateTime?
}

model DigestRunLog {
  id              String   @id @default(cuid())
  runAt           DateTime @default(now())
  newSendersFound Int      @default(0)
  digestEmailSent Boolean  @default(false)
  historyIdBefore String?
  historyIdAfter  String?
  errorMessage    String?
}
```

3. Create `.env.example` listing these variables with a comment on each, and copy it to `.env`:
   - `DATABASE_URL`, `DIRECT_DATABASE_URL`
   - `APOLLO_API_KEY`
   - `TOKEN_ENCRYPTION_KEY` (32 random bytes as hex)
   - `CRON_SECRET`
   - `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` (`http://localhost:3000/api/gmail/oauth/callback`)
   - `NEXT_PUBLIC_APP_URL`

   Only the database, Apollo, and encryption values are needed now. Never commit `.env`.
4. Run `npx prisma migrate dev --name init`, then `npx prisma generate`.
5. Write `src/lib/db.ts`. It should throw a clear error if `DATABASE_URL` is missing, create
   `new PrismaClient({ adapter: new PrismaPg({ connectionString }) })` (imported from
   `@/generated/prisma/client`), and cache the client on `globalThis` outside production.
6. Add `GET /api/health`. It runs `` db.$queryRaw`SELECT 1` `` and returns `{ ok: true, db: "connected" }`,
   or a 500 status on failure.

**Checkpoint:** `curl localhost:3000/api/health` returns `{"ok":true,"db":"connected"}`, and
`npx prisma studio` shows all six tables.

## Step 3: Look and feel

The look is **editorial and academic, not dashboard-y**: white paper, deep navy text, **one** crimson
accent, serif headlines. Define these in `globals.css` and map shadcn's tokens onto them in
`@theme inline` (background → paper, foreground → navy, primary/ring → red, border/input → line):

```css
:root {
  --paper: #ffffff;  --paper-2: #f3f5f9;  --paper-3: #e9edf4;
  --navy: #000e2f;   --navy-soft: #2a3a5f;
  --red: #e4002b;    --red-deep: #b80022;
  --slate: #545454;  --silver: #a6a6a6;   --line: #dce2ec;
  --serif: "Times New Roman", Times, serif;
  --body: Calibri, "Segoe UI", system-ui, -apple-system, sans-serif;
  --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  --radius: 0.875rem;
}
```

Build a small set of house classes. Pages use these rather than shadcn cards:

- **Chrome:**
  - a fixed 3px crimson bar across the top
  - the logo as a faint watermark (~5% opacity) fixed in the bottom-right
  - a max-width 1280px content wrapper
  - a brand lockup: 54px logo plus the wordmark "Connect**.AI**" in serif, with ".AI" in crimson
- **Nav:** mono, 11px, uppercase, wide letter-spacing, hairline rules above and below. The active item
  is crimson.
- **Page head:**
  - a mono "eyebrow" label with a short crimson tick
  - a big serif title (`clamp(38px, 5vw, 60px)`) whose final phrase is an *italic crimson* `<em>`
  - a short lede paragraph

  Make this a `PageHead({ eyebrow, title, accent, lede })` component.
- **Building blocks:**
  - stat tiles with huge serif numbers
  - "rows" lists with a mono crimson key column
  - tables with a 2px navy header rule and serif company names
  - pill badges (ready / navy / starter)
  - crimson pill buttons with mono uppercase text and a ghost variant
  - square-cornered inputs
  - toggle chips (selected = navy)
- **Motion:** a subtle fade-up on page load, disabled under `prefers-reduced-motion`.
- No dark mode.

Put the chrome, a `SiteNav` client component (it uses `usePathname` for the active state), `<main>`,
and the footer "Connect.AI · Student-led forward-deployed engineering" in a `(dashboard)` route group
layout. The root layout sets the metadata title "Connect.AI" and renders the sonner `<Toaster />`.
Give `/outreach` and `/inbox-alerts` a `ComingSoon` component: a `PageHead` plus a "Not built yet" badge.

**Checkpoint:** every nav link renders a styled page, and the look matches the description above.

## Step 4: Apollo client

Put everything in `src/lib/apollo/`. Base URL: `https://api.apollo.io/api/v1`. Send the headers
`x-api-key: $APOLLO_API_KEY`, `Content-Type: application/json`, and `Accept: application/json`.
Write one `apolloRequest<T>(path, { method, body?, query? })` helper. On a non-OK response it should
throw an `ApolloError(message, status)`, taking the message from the body's `error_message` or `error`.

| Function | Endpoint | Cost | Notes |
|---|---|---|---|
| `searchCompanies` | `POST /mixed_companies/search` | free | Send `organization_locations`, `organization_num_employees_ranges` (e.g. `"1,10"`), `q_organization_keyword_tags`, `page`, and `per_page` (max 100). Map the `organizations[]` and `pagination` fields. Results carry **identity only** (id, name, `primary_domain`, website, LinkedIn, founded year): no industry, size, or location |
| `searchPeople` | `POST /mixed_people/api_search` | free | Send `organization_ids` or `q_organization_domains_list`, plus `person_titles`. Results have first name, **obfuscated** last name, title, and `has_email`. Never an email |
| `enrichOrganizationsByDomain` | `POST /organizations/bulk_enrich` `{ domains }` | 1 credit per match | **Max 10 domains per call**, so chunk them. Returns a Map keyed by the requested domain; match on `primary_domain`, falling back to `website_url` containing the domain. Provides industry, `estimated_num_employees`, city, state, and country |
| `revealPerson` | `POST /people/match` `{ id }` | 1 credit | Returns the full name, `email`, `email_status`, title, and LinkedIn |

Keep the free functions and the credit-spending functions in separate files, with a comment at the top
of each saying which is which.

**Checkpoint:** call `searchCompanies({ locations: ["Connecticut, US"], employeeRanges: ["1,10"] })`
from a scratch script or route and see real companies come back.

## Step 5: API routes

All routes are `POST` with JSON bodies. They return `{ error }` with a sensible status on failure: 400
for bad input, 404 when something isn't found, 502 when Apollo errors.

- `/api/apollo/search-companies`: requires at least one filter; returns the search page.
- `/api/apollo/search-people` `{ companyId, titles }`: looks up the company. It searches by
  `apolloOrgId` if the company has one, otherwise by domain, and returns 400 if it has neither.
- `/api/companies/import` `{ companies }`: enriches all the domains in **one bulk pass**, then
  **upserts** each company. The upsert key is `domain` if present, otherwise `apolloOrgId`. Enriched
  values win over search values. Returns `{ imported, skipped: string[] }`.
- `/api/contacts`: adds a contact by hand. Email is required; trim and lowercase it. Set
  `source: "manual"`. A duplicate email returns 400 "A contact with this email already exists."
- `/api/contacts/reveal` `{ companyId, apolloPersonId }`: calls `revealPerson`. Returns 404 if there's
  no match and 422 if there's no email. Otherwise upserts the contact by lowercased email with
  `source: "apollo"` and returns `{ contact, emailStatus }`.

**Checkpoint:** `curl` each route with a realistic body. The search routes should work without spending
credits.

## Step 6: Pages

Any server page that reads the database exports `dynamic = "force-dynamic"`.

- **Overview `/`:** one `Promise.all` for the four counts. Emails sent = OutreachEmail with
  `status: "sent"`. New senders = InboundEmailEvent with no matched contact. Also fetch the 6 newest
  companies with `_count.contacts`. Title: "Every company you could reach. *One place.*"
- **Find companies (client page):**
  - A location input (default "Connecticut, US") and a comma-separated keywords input.
  - Employee chips: 1–10, 11–20, 21–50, 51–100, 101–200, 201–500, 501–1000, 1001–5000, 5001–10000,
    10001+. The first three are on by default.
  - Search requests 25 results per page. The results table has checkboxes, "Select all", and
    Previous/Next paging.
  - "Add N to companies" calls import, shows a toast, then navigates to `/companies`.
- **Companies:** a table sorted newest first. The empty state links to Find companies.
- **Company detail:**
  - A header with the domain, employee count, LinkedIn, and notes, followed by a contacts list.
  - A `FindPeople` client component: title chips (Founder, Owner, CEO on by default; President, Partner,
    Director off), a results table, and a "Reveal & save" button that's disabled when `hasEmail` is
    false. Revealing shows a toast and refreshes the page.
  - An `AddContactForm` with first name, last name, email, and title.
- **Contact detail:** a link back to the company, then email, LinkedIn, a status badge, and outreach
  history. The empty history says Gmail sending arrives in the next phase.
- **Settings:** a server page that checks which env vars are present and shows a Configured/Missing
  badge for each integration.

**Checkpoint (end to end):** search Connecticut companies, import 2 (spends about 2 credits, so ask
the user first), open one, find its founder, reveal and save them (1 credit), and see the contact on the
company page and in the Overview counts.

## Step 7: Finish

1. `npm run build` passes. The build needs `DATABASE_URL` to be *set*, but doesn't connect.
2. `npx tsc --noEmit` passes. Run it after the build, which generates the route types.
3. `npm run lint` passes.
4. `git init` and commit. Confirm `.env` and `src/generated/` are not in the commit.

## Later phases (only when the user asks)

- **Phase 3, Gmail outreach:**
  - Google OAuth (Gmail API, consent screen in Testing mode, redirect `/api/gmail/oauth/callback`).
  - Store the refresh token in `GmailIntegration`, encrypted with AES-256-GCM. Derive the key from
    `TOKEN_ENCRYPTION_KEY` via scrypt and store it as `iv.tag.ciphertext` in base64.
  - Send emails from the contact page, log each one to `OutreachEmail`, and set the contact's status to
    `emailed`.
- **Phase 4, daily inbox digest:**
  - A scheduled Claude Code routine calls `POST /api/routines/daily-inbox-check` with an
    `x-cron-secret` header.
  - The route reads Gmail history since `lastHistoryId`, records senders who aren't contacts as
    `InboundEmailEvent`, emails a digest linking back to `NEXT_PUBLIC_APP_URL`, and logs a `DigestRunLog`.
