# Lead Forms

> **[Docs Overview](./overview.md)** / Lead Forms

Developer reference for the dealer contact/lead forms - how they're configured, how a submission is routed to the right dealer, and what happens after a lead is captured. The same endpoint carries the spam/bot defenses documented in [SPAM_PROTECTION.md](./SPAM_PROTECTION.md).

---

## At a glance

- **Endpoint:** `POST /api/contact` (`app/api/contact/route.ts`)
- **Form UI:** `app/components/contact-section.tsx`
- **Settings UI:** `components/LeadFormSettingsModal.tsx` (saved via `/api/dealer/settings`)
- **Config type:** `LeadFormSettings` in `types/lead-form.ts`, stored on `Dealer.settings.leadFormSettings` (JSON)
- **Storage:** `Lead` table (Prisma)
- **Management:** `app/dashboard/leads/page.tsx`
- **Tier gate:** Growth and above (`hasLeadFormAccess()`)

---

## Configuration

Lead form behavior is per-dealer config stored in the `Dealer.settings` JSON blob under `leadFormSettings`. The shape (`types/lead-form.ts`):

```ts
interface LeadFormSettings {
  leadFormEnabled: boolean; // does the form appear / accept submissions
  autoResponseEnabled: boolean; // email the submitter a thank-you
  autoResponseContent: string; // HTML (from the Lexical editor)
  emailNotificationEnabled: boolean; // email the dealer about the new lead
  notificationEmail?: string; // override; defaults to Dealer.contactEmail
}
```

Defaults (`DEFAULT_LEAD_FORM_SETTINGS`): form **enabled**, auto-response **off**, dealer notification **on**, notification email falls back to `Dealer.contactEmail`. The route merges stored settings over the defaults, so missing keys take the default:

```ts
const settings = { ...DEFAULT_LEAD_FORM_SETTINGS, ...(dealerSettings.leadFormSettings ?? {}) };
```

Dealers edit these in `LeadFormSettingsModal.tsx` (toggle form, toggle/edit auto-response HTML, toggle notifications, override notification email).

---

## Submission pipeline

`POST /api/contact` runs, in order:

1. **Spam/bot checks** - Turnstile → honeypot → timing. See [SPAM_PROTECTION.md](./SPAM_PROTECTION.md). (These run _before_ dealer identification.)
2. **Payload validation** (`validatePayload`) - `name`, `email`, `message` required; `phone` optional. Length caps: name ≤ 255, email ≤ 254 (RFC 5321), phone ≤ 20, message ≤ 10000. Basic email-format regex. Returns field-level `400` errors on failure.
3. **Dealer identification** - see below. Fails closed with `400` if no unambiguous dealer.
4. **Enabled check** - if `settings.leadFormEnabled` is false, `403`.
5. **Store** - `prisma.lead.create({ data: { dealerId, name, email, phone, message } })` (primary action; timestamp auto-set).
6. **Auto-response email** (if enabled) - to the submitter; subject `Thank you for contacting {dealerName}`; body is the dealer's `autoResponseContent`, run through `sanitizeHtml()`.
7. **Dealer notification email** (if enabled) - to `notificationEmail || dealer.contactEmail`; `Reply-To` is the lead's name/email; body links to `/dashboard/leads`.

Both emails are **best-effort**: a send failure is logged (`logger.warn`) but does **not** fail the submission - the lead is already stored.

---

## Dealer identification (the important part)

A submission must be attributed to exactly one dealer. Getting this wrong leaks a lead's PII (name, email, phone, message) and the auto-response branding to the wrong dealer, so the route **fails closed** rather than guessing.

> **Why a subdomain alone is unsafe:** two dealer records can legitimately share a subdomain across parent domains - `bobsoil.myamsoil.com` and `bobsoil.shopamsoil.com` are different dealers. A `findFirst({ where: { subdomain } })` returns an arbitrary sibling. Resolution must use the **full tuple** `(subdomain, domainPrefix, domain)`.

The route tries identification methods in priority order:

1. **`dealerNumber`** (from the payload) - most reliable, but `dealerNumber` is **not** `@unique`. The route fetches up to 2 candidates: uses it only if **exactly one** matches; if more than one match, it falls through (ambiguous) rather than picking one.
2. **Subdomain tuple** - assembled from, in priority order:
   - proxy-injected headers `x-dealer-subdomain` / `x-dealer-domain-prefix` / `x-dealer-domain` (most trusted; the proxy strips client-supplied copies);
   - the payload's `domainPrefix` / `domain` (used **atomically** - both or neither - and validated against the whitelist in `lib/domain-config.ts`; required for the dev host `amsoil-dlp.acdev3.com`, which has no wildcard DNS / header injection);
   - `extractDealerHostParts(host)` for production-style subdomain hosts (last resort).

   Only when **all three** of `subdomain`, `domainPrefix`, `domain` are present does it run the unambiguous `findFirst({ where: { subdomain, domainPrefix, domain } })`. A subdomain without its parent-domain tuple is logged and rejected - **never** queried subdomain-only.

3. **Fail closed** - if no method yields a single dealer, return `400 "Unable to identify dealer."`.

> The same tuple problem appears in analytics ingest (`/api/stats/events`), which uses the proxy headers / host parsing too - see [analytics/README.md](./analytics/README.md).

---

## Security hardening

User-supplied content reaches email, so the route hardens it:

- **`sanitizeForEmailHeader()`** - strips newlines, carriage returns, tabs, and control chars from name/email used in headers (prevents email-header injection).
- **`escapeHtml()`** - escapes `& < > " '` in the HTML body of the dealer notification (prevents XSS in email clients).
- **`sanitizeHtml()`** (`lib/cms/sanitize`) - sanitizes the dealer's `autoResponseContent` before sending.

---

## Tier gating

The lead form is a Growth+ feature. The gate is `hasLeadFormAccess(tier)` (`LEAD_FORM_TIERS` in `lib/cms/types.ts`): Starter ❌, Growth ✅, Enhanced ✅, Professional ✅. The full matrix and the rule "use accessors, never inline `tier === ...`" live in [PLAN_FEATURES_MATRIX.md](./PLAN_FEATURES_MATRIX.md).

---

## Lead management

Captured leads appear in the dealer dashboard at `app/dashboard/leads/page.tsx` - view, search, delete (single/bulk), and export. The list API returns `LeadData` (`types/lead-form.ts`). Lead **count** also feeds analytics as the "inquiries" metric ([analytics/README.md](./analytics/README.md)).

---

## Related

- [SPAM_PROTECTION.md](./SPAM_PROTECTION.md) - the defenses on this endpoint
- [PLAN_FEATURES_MATRIX.md](./PLAN_FEATURES_MATRIX.md) - tier gating
- [analytics/README.md](./analytics/README.md) - leads as the "inquiries" metric
- [EMAIL_SERVICE.md](./EMAIL_SERVICE.md) - the email transport used for notifications
- [DEVELOPER_OVERVIEW.md](./DEVELOPER_OVERVIEW.md) - developer onboarding entry point
