# Pilot Invitation Manager — task document (2026-09-14)

Source: Nash's written brief "Build the Pilot Invitation Manager Under Moderator Tools" (§1–§31) plus two screenshots: (a) the Alaska pilot directory listing filtered on **AK · High Pole**, (b) the directory's filter panel (Escort positions + Additional Requirements).

Scope: the foundation of the HeavyHaul Agent Pilot Network — unclaimed pilot profiles, the admin Pilot Invitation Manager, invitation email templates, controlled campaigns with a daily limit and one follow-up, claim-profile flow, suppression, history, analytics, audit log, and an import structure for the full state-by-state dataset (§27). **Not** a demo page (§31). Out of scope (§29): load board, marketplace, GPS matching, nearby alerts, auto-assignment, SMS campaigns, ratings, reviews, public directory, automatic document approval.

---

## 0. Sample dataset (from screenshot a — the only data to insert, §7)

The listing is filtered to AK and High Pole, so it is a subset of Alaska. Four records, exactly as shown. Company blank where none is shown. Nothing added, nothing looked up.

| # | Name | Company | Phone | Email | State | Capabilities (directory order) |
|---|---|---|---|---|---|---|
| 1 | Jermond Thompson | — | (832) 229-1089 | isathompsonjermond@gmail.com | AK | Lead, Chase, High Pole, Steer, Route Survey |
| 2 | Michael Remala | Immanuel PCS, LLC | (480) 244-2636 | mremala@yahoo.com | AK | High Pole, Chase, Lead, Third Car, Fourth Car |
| 3 | Stephen Harris | A Plus Expeditor's | (214) 484-4201 | aplusexpeditors@gmail.com | AK | High Pole, Steer, Route Survey, Lead, Chase, Third Car, Fourth Car |
| 4 | Chey Hollingsworth | We Keep Ya Rollin' | (240) 452-9965 | cheysey71@comcast.net | AK | Lead, Chase, High Pole, Route Survey |

Defaults on every row (§5): `account_type = unknown`, `claim_status = unclaimed`, `invitation_status = imported`, `email_opt_in_status = not_opted_in`, `sms_opt_in_status = not_opted_in`, `primary_state = AK`, `related_states = ['AK']`. Capability rows carry `data_status = imported` (§22). `source = 'pilotcarloads.com'`, `source_reference = 'AK listing · High Pole filter · 2026-09-14'` — internal only (answer 7).

Screenshot (b) vocabulary — Escort positions: Lead, Chase, High Pole, Steer, Route Survey, Third Car, Fourth Car. Additional Requirements: WITPAC Needed, CEVO Needed, CSE Needed, Passport Needed, NY Certified (see Q2).

---

## 1. Current state of the codebase (verified)

| Area | What exists | Reuse / gap |
|---|---|---|
| Admin area | `/admin/users`, `/admin/moderation` (Moderator Dashboard), `/admin/email-templates`, `/admin/company-review`, `/admin/pilot-preview`; pages gate on `user.role === 'admin'` (`getSessionUser`), links in `src/components/app/user-menu.tsx` and the testing bar (`role-switcher.tsx`) | New page `/admin/pilot-invitations` + link in both places. Same gate. |
| Email templates | Table `email_templates` (+ `email_template_versions`) from migration 0008; code defaults in `src/lib/email-templates.ts` (`DEFAULT_TEMPLATES`, `TEMPLATE_VARIABLES`, `renderTemplate` with `{{var}}`); admin UI `templates-admin.tsx`: edit subject/body, preview with sample data, version history + restore, test-send (**recorded only**), `active` flag. **No "create new template" UI, no CTA/footer/unsubscribe fields.** | Extend rather than duplicate: add a `category = 'pilot_invitation'` template group, create-template, CTA text, footer, unsubscribe wording, pilot variables. |
| Email sending | **None.** No provider, no adapter, no env key (env names: AUTH_*, SUPABASE_*, FLASK_*, SYNCHRON_*, NEXT_PUBLIC_APP_URL). Every "send" in the app is recorded, not delivered. | Q1 decides the provider. Build the send pipeline behind an adapter; deliverability, bounces and complaints need the provider's webhooks. |
| Audit log | `trip_events` only (`src/lib/audit.ts`, trip-scoped); moderation has ticket events. No admin-wide audit table. | New `pilot_network_audit_log` table (§26). |
| Import infrastructure | Migration `0017_external_sources_import.sql` (other session, **uncommitted, not applied**): `import_runs`, `import_records`, `(source_system, source_record_id)` provenance pattern, `trip_participants.status = 'imported'` + `claimImportedTrips` (claim after email verification). | Reuse `import_runs`/`import_records` for the full pilot import (§27) and the provenance pattern for dedupe (§28). |
| Capabilities | `PILOT_CAPABILITIES` in `src/lib/domain/pilot.ts` (in-app names: Lead pilot, Chase / rear pilot, High pole, Steer / tillerman support, Route survey, …). The directory uses different names and adds Third Car / Fourth Car. | New `pilot_capability_types` table seeded from the directory vocabulary; optional mapping to the in-app names (Q3). |
| Pilot accounts | `UserRole` = broker · dispatcher · driver · admin; `auth_accounts.role` check allows only those four. `/signup/pilot` is an **internal-only replica** (3 steps: type → details → validate email) on demo data. No pilot account creation, no email verification backend. | The claim flow can prefill and hand into that wizard; real account creation depends on pilot roles (Q4). |
| Unsubscribe / suppression | Nothing. | New (§23). |
| States | `STATE_NAMES` in `src/lib/domain/states.ts` (all states). | State filter source. |
| Migrations | Applied by Nash in the Supabase SQL editor (service key cannot run DDL); `scripts/verify-migration-00NN.mjs` pattern. 0016 and 0017 are still **not applied**. | New migration `0018_pilot_network.sql` + verify script + seed script. |

Working-tree note: another session has uncommitted changes (0017 migration, `src/lib/auth.ts`, `trip-page.ts`, `trips.ts`, `driver.ts`, `db.ts`, moderation page). This work must not touch or commit those files.

---

## Module A — Data model (migration `0018_pilot_network.sql`)

**Task A1 — `pilot_profiles` (Unclaimed Pilot Profile, §5).** Columns exactly as listed: `id`, `name`, `company_name` (nullable), `email`, `phone`, `primary_state`, `related_states` (text[]), `account_type` (`unknown` | `pilot_driver` | `pilot_company` — set only when claimed, §4/§20), `claim_status` (`unclaimed` | `claimed`), `invitation_status` (§11 values: `imported`, `ready_for_review`, `approved_for_invitation`, `invitation_sent`, `follow_up_sent`, `claimed`, `opted_in`, `unsubscribed`, `invalid`, `duplicate`, `do_not_contact`), `email_opt_in_status` (`not_opted_in` | `opted_in` | `unsubscribed`), `sms_opt_in_status` (`not_opted_in` | `opted_in` | `unsubscribed` — stored only; no SMS sending, §29), `source`, `source_reference`, `last_contacted_at`, `next_follow_up_at`, `claimed_user_id`, `claimed_company_id`, `admin_notes`, `created_at`, `updated_at`. Plus `claim_token` (unique, nullable — set to null when claimed, answer 6) and `unsubscribe_token` (unique). Indexes: lower(email), phone digits, primary_state, invitation_status, claim_status. RLS on, no policies (service role only, as every table since 0002).

**Task A2 — Structured capabilities (§6).** `pilot_capability_types` (`id`, `key`, `label`, `group` = `position` | `certification`, `sort_order`, `active`) seeded with positions Lead, Chase, High Pole, Steer, Route Survey, Third Car, Fourth Car and certifications WITPAC, CEVO, CSE, Passport, NY Certified (answer 2). Third Car and Fourth Car are also added to the in-app position list `PILOT_CAPABILITIES` (answer 3). `pilot_profile_capabilities` (`profile_id`, `capability_type_id`, `data_status` = `imported` | `user_confirmed` | `hha_reviewed` | `document_verified` (§22), `source`, `confirmed_at`, unique per profile+type). New types are rows, never schema changes.

**Task A3 — Invitation history (§24).** `pilot_invitation_events` (`profile_id`, `campaign_id` null, `template_id` null, `kind` = `invitation_sent` | `follow_up_sent` | `test_sent` | `delivered` | `bounced` | `complaint` | `unsubscribed` | `claimed` | `opted_in` | `opened_claim_link`, `email_status`, `provider_message_id`, `detail` jsonb, `created_at`).

**Task A4 — Suppression list (§23).** `pilot_suppressions` (`email` lower, unique; `reason` = `unsubscribed` | `invalid_email` | `bounced` | `duplicate` | `do_not_contact` | `complaint` | `manual_block`; `profile_id`; `note`; `created_by`; `created_at`). A suppressed email never receives a campaign or follow-up email.

**Task A5 — Campaigns (§17–§19).** `pilot_campaigns` (`name`, `state`, `template_id` initial, `follow_up_template_id`, `daily_send_limit` int (configurable, no fixed number, §18), `follow_up_delay_days` int, `start_date`, `status` = `draft` | `running` | `paused` | `stopped` | `completed`, `created_by`, timestamps). `pilot_campaign_recipients` (`campaign_id`, `profile_id`, `status` = `pending` | `sent` | `follow_up_due` | `follow_up_sent` | `done` | `skipped`, `sent_at`, `follow_up_at`, `skip_reason`, unique per campaign+profile).

**Task A6 — Audit log (§26).** `pilot_network_audit_log` (`actor_id`, `actor_label`, `action`, `entity_type` = profile | campaign | template | setting, `entity_id`, `detail` jsonb, `created_at`). Server helper `logPilotNetworkAction()` next to `src/lib/audit.ts`. Every admin action in Modules C–F writes a row; claim, opt-in and unsubscribe events from the public pages write rows too.

**Task A7 — Email template extensions (§14–§15).** Add to `email_templates`: `category` (existing templates = `system`, new = `pilot_invitation`), `cta_text`, `footer`, `unsubscribe_text`. Pilot variables added to the variable list: `name`, `company_name`, `state`, `states`, `capabilities`, `claim_profile_link`, `unsubscribe_link`, `support_email`.

**Task A8 — Verify + seed scripts.** `scripts/verify-migration-0018.mjs` (prints APPLIED / NOT APPLIED like 0016/0017). `scripts/seed-pilot-leads-ak-2026-09-14.mjs` inserts the four §0 rows and their capability rows through the import structure of Task G1 (so the sample is import run #1, traceable in `import_records`). Idempotent on email.

---

## Module B — Invitation email templates (§13–§16)

**Task B1 — Template group in the admin.** In `/admin/email-templates` add a "Pilot Invitation Email Templates" section (§14) listing `category = pilot_invitation` templates. Actions: create template, edit subject, edit body, edit CTA text, edit footer, edit unsubscribe wording, preview with a real lead's data, send test email (to an admin-entered address; delivery per Q1), activate / deactivate, save (a version row on every save — existing behaviour). *Existing:* edit/preview/versions/active flag. *New:* create, CTA/footer/unsubscribe fields, category, pilot variables, preview from a lead.

**Task B2 — Seed two code-default templates.**
- `pilot_invitation_initial` — subject and body exactly as §16: subject `Pilot car opportunities in {{state}} — claim your HeavyHaul Agent profile`; body "Hello {{name}}, … [Claim My Pilot Profile] … If you do not want further invitations, you can unsubscribe below. HeavyHaul Agent"; CTA text `Claim My Pilot Profile` (§13); footer + unsubscribe wording with `{{unsubscribe_link}}`.
- `pilot_invitation_follow_up` — day-N follow-up (§19), a managed template the moderator team edits like the initial one (answer 8); ships with a short draft that repeats the invitation with the same CTA and unsubscribe line.

**Task B3 — Rendering rules (§15).** `renderTemplate` extended so every listed variable resolves; `{{company_name}}` empty must not leave a dangling line or "at ," (render the company line only when present); `{{states}}` = comma list of related states; `{{capabilities}}` = comma list in directory order; `{{claim_profile_link}}` / `{{unsubscribe_link}}` from the profile tokens on `NEXT_PUBLIC_APP_URL`. Unit tests: with and without company; no unresolved `{{…}}` left.

---

## Module C — Pilot Invitation Manager page (`/admin/pilot-invitations`, §8–§12)

**Task C1 — Page + access.** Server page gated like `/admin/moderation` (`user.role === 'admin'`; otherwise redirect). Link: admin user menu, next to Moderator Dashboard (not the testing bar — answer 9). Title "Pilot Invitation Manager". Internal look, not public.

**Task C2 — Summary cards (§9).** Total Pilot Leads · Unclaimed Profiles · Approved for Invitation · Invitations Sent · Profiles Claimed · Opted Into Alerts · Unsubscribed · Bounced / Invalid Emails. Counts from `pilot_profiles` + suppressions.

**Task C3 — Leads table (§10).** Columns: Name · Company (`—` when null, never fabricated) · State / States · Phone · Email · Capabilities (chips) · Claim Status · Invitation Status · Last Contacted · Actions. Row click opens the detail (Task C5). Paginated (thousands later, §27).

**Task C4 — Search and filters (§11).** Search box over name, company, email, phone. State filter over all states (`STATE_NAMES`), matching `primary_state` or `related_states`. Capability filter (multi-select from `pilot_capability_types`). Status filter with the twelve §11 values. Filters combine; URL-backed so a view can be shared.

**Task C5 — Profile detail drawer (§12).** Shows name, company, phone, email, state relationships, capabilities (with their data status badge: Imported / User confirmed / HHA reviewed), source + reference, claim status, invitation status, email opt-in, SMS opt-in, last contact, follow-up date, admin notes, invitation history (Task A3 rows) and communication history. Actions: Approve for Invitation · Send Invitation · Preview Email · Resend Invitation · Mark Duplicate · Mark Invalid · Mark Do Not Contact · Edit Profile · Add Admin Note · View Communication History. Each action = an API route under `/api/admin/pilot-invitations/*`, admin-gated, audit-logged (Task A6). Mark Invalid / Duplicate / Do Not Contact also write a suppression row (Task A4) and set `invitation_status`.

---

## Module D — Sending pipeline, campaigns, follow-up (§13, §17–§19)

**Task D1 — Email delivery adapter.** `src/lib/adapters/email.ts` with one `sendEmail({to, subject, html, text, tags})` behind a provider chosen in env (Q1). Until a provider key exists the adapter runs in `recorded` mode: the send is logged in `pilot_invitation_events` with `email_status = recorded_not_delivered` and shown as such in the UI — never silently "sent". Rendering uses Module B templates.

**Task D2 — Single-profile send.** "Send Invitation" / "Resend Invitation" from the drawer: refuse if suppressed, claimed, or status is invalid/duplicate/do-not-contact; otherwise render the active initial template, send through D1, set `invitation_status = invitation_sent`, `last_contacted_at`, `next_follow_up_at = now + campaign/default delay`, write history + audit.

**Task D3 — Campaign CRUD (§17).** Create campaign: name, state, contacts (pick from the filtered table, or "all Approved for Invitation in this state"), initial template, follow-up template, daily send limit (free integer, presets 10/25/50/100, §18), start date, follow-up delay days. Preview recipients (list with count and who would be skipped and why), send test email, Start, Pause, Resume, Stop. Each transition audit-logged. Campaign list + detail with per-recipient status.

**Task D4 — Daily batch runner (§18–§19).** One server function `runPilotCampaignsDay()` that, for each running campaign whose start date has passed: sends up to `daily_send_limit` pending recipients (initial email), then sends follow-ups for recipients whose `follow_up_at` is due — only one follow-up ever, then `done`. Skips (and records the reason) when the profile has claimed, unsubscribed, bounced, is invalid, duplicate, or do-not-contact (§19). Trigger: an admin-gated `/api/admin/pilot-invitations/run-day` route (also callable by the host cron with a secret header), a "Run today's batch" button, and the **cron on/off switch** on the manager page (answer 5). Per-calendar-day counting makes repeated calls safe.

---

## Module E — Claim profile flow (§20–§22) and unsubscribe (§23)

**Task E1 — Claim page `/pilot/claim/[token]`.** Public (no login), token from the profile; once claimed the token is erased and the link shows "already claimed" (answer 6). Shows what we have: name, company, phone, email, state(s), capabilities each marked "Imported from directory". Asks "What describes you?" → Pilot Driver / Pilot Company · Pilot Dispatch (§20). Every imported field is editable. Capability step (§21): "We currently have the following capabilities associated with your profile." with Confirm / Remove / Add more (from `pilot_capability_types`). On submit: `claim_status = claimed`, `account_type` set, `invitation_status = claimed`, capabilities kept become `user_confirmed`, removed ones deleted, added ones `user_confirmed`; events + audit written; the person continues into pilot onboarding — target: a **real account** (answer 4). Implementation checks whether the current auth (`auth_accounts`, env logins, no email verification backend) can create a pilot account; if pilot roles are not in place yet, the claim is recorded and the wizard runs as the existing replica, with real account creation as the stated next step. Opening the link records `opened_claim_link`.

**Task E2 — Opt-in.** On the claim page a clear choice to receive future pilot car requests (`email_opt_in_status = opted_in`, `invitation_status = opted_in`, event `opted_in`). Not pre-checked.

**Task E3 — Unsubscribe page `/pilot/unsubscribe/[token]`.** Public; one click confirms; writes suppression (`unsubscribed`), sets `email_opt_in_status = unsubscribed`, `invitation_status = unsubscribed`, event + audit. Idempotent.

**Task E4 — Data status labels (§22).** Everywhere a capability is shown (admin drawer, claim page, later the pilot profile) the badge reads Imported / User confirmed / HeavyHaul Agent reviewed / Document verified from `data_status`; they are never merged.

---

## Module F — Analytics (§25)

**Task F1 — Analytics panel** on the manager page (below the cards or a tab): total imported, invitations sent, claimed, opted in, unsubscribes, bounce rate (bounced ÷ sent; needs provider webhooks, Q1), claim conversion rate (claimed ÷ sent), profiles by state, profiles by capability. Plain tables/bars; no external chart service.

---

## Module G — Import structure for the full dataset (§27–§28)

**Task G1 — Import function** `importPilotLeads(rows, {source_system, run_by, dry_run})` taking rows shaped `{name, company?, phone, email, state, capabilities[]}` (§27). Creates an `import_runs` row and one `import_records` row per input (created / updated / skipped / failed with message). Unknown capability names create new `pilot_capability_types` rows (inactive until an admin reviews, so the vocabulary grows without schema changes, §6).

**Task G2 — Duplicate handling (§28).** Match order: email (case-insensitive) → phone (digits only) → name + company (normalised). A match merges: add the state to `related_states` (one profile, many states — AZ, NM, TX example), union capabilities, keep the earliest `primary_state`, record `updated` in `import_records`. Never a second profile. Unit tests for all three match paths and the multi-state merge.

**Task G3 — Admin entry points.** "Import" button on the manager page accepting a CSV with those columns (dry run first, then commit) and a `scripts/import-pilot-leads.mjs` CLI for the other developer's bulk file. Both go through G1.

---

## Module H — Verification and hand-off

- `npx tsc --noEmit`, eslint on touched files, vitest for B3, G2, status transitions (D2/D4 skip rules).
- Migration 0018 applied by Nash (SQL editor), then `node scripts/verify-migration-0018.mjs`, then the seed script → four AK leads visible in the manager.
- Browser check of the manager (cards, table, filters, drawer, actions), template section, campaign create/preview, claim page and unsubscribe page with the sample leads.
- Record outcomes here.

---

## Questions before implementation (answer these; everything else is specified above)

1. **Email delivery provider.** Nothing in the project can send email today; "Send Invitation", test emails, campaigns, bounce rate and complaints all need one (Resend, Amazon SES, SMTP, …) plus a verified sending domain and an API key in `.env`. Which provider, and do you want the whole pipeline built now in "recorded, not delivered" mode until the key arrives?
2. **Additional Requirements** from screenshot (b) — WITPAC Needed, CEVO Needed, CSE Needed, Passport Needed, NY Certified. The brief only names the escort positions. Seed these as a second capability group ("requirements / certifications"), or leave them out for now?
3. **Capability naming.** The directory says Lead, Chase, High Pole, Steer, Route Survey, Third Car, Fourth Car; the app's pilot profile uses Lead pilot, Chase / rear pilot, High pole, Steer / tillerman support, Route survey, and has no Third Car / Fourth Car. Store the directory names as the capability types (as the brief implies) and map to the in-app names on claim, adding Third Car and Fourth Car to the in-app list — or keep the two vocabularies separate?
4. **How far the claim flow goes today.** Pilot accounts do not exist yet (no pilot role in auth, no email verification backend; `/signup/pilot` is an internal replica). Option A: the claim page records the claim, type, corrections, capabilities and opt-in, then shows "your profile is claimed — onboarding continues when pilot accounts launch" and hands into the existing replica for internal review. Option B: this task also builds real pilot account creation (pilot roles in auth, email verification). Which?
5. **Daily batch trigger.** The daily send limit needs something to run once a day. Options: a scheduled hit on the admin route (host cron — the repo has a cPanel packaging script), or admin-only "Run today's batch" button for now. Which, and on which host?
6. **Claim link lifetime.** One unique token per profile, no expiry, invalid after the profile is claimed — OK?
7. **Source label.** What should `source` / `source_reference` say for these four rows — the directory's name is not in the brief. Proposed: `source = 'pilot_directory'`, `source_reference = 'AK listing · High Pole filter · screenshot 2026-09-14'`.
8. **Follow-up email wording** (day 10). The brief gives the initial email only. Provide wording, or approve a draft that repeats the invitation in shorter form with the same CTA and unsubscribe line?
9. **Page location.** Proposed: its own page `/admin/pilot-invitations` linked from the admin user menu and the testing bar, rather than a tab inside the Moderator Dashboard. OK?

---

## Client answers (2026-09-14, in progress)

1. **Email provider:** none for now. "We will not send any emails, we are just preparing the back end and the design so it's all ready." Build the whole pipeline in recorded-not-delivered mode; the delivery step is a later phase.
2. **Additional Requirements (WITPAC, CEVO, CSE, Passport, NY Certified):** these are **certifications a pilot car driver holds on their profile** (a carrier may require them, e.g. Washington / New York certification). Seed them as a second capability group `certification`, separate from the escort positions.
3. **Third Car / Fourth Car** are escort **positions** (one chase, two chases, third, fourth…). Add them to the app's position list in the pilot profile; store the directory names as the capability types.
4. **Claim flow:** the goal is a **real account** once the profile is claimed, so the pilot can get into the system — build real pilot account creation if authentication allows; if not yet, ship it as a replica now with real account creation as the target.

5. **Daily batch trigger:** a **cron job the admin can turn on and off** from the manager page; it runs continuously while on and is stopped when needed. Implementation: an `enabled` switch stored in a `pilot_network_settings` row and shown on the manager page (toggle audit-logged); the runner route `/api/admin/pilot-invitations/run-day` does nothing while the switch is off; the host scheduler (cPanel cron, curl with a secret header) calls the route on a schedule. The daily send limit is counted per calendar day, so extra calls in the same day send nothing more. "Run today's batch" stays as a manual button.
6. **Claim link:** one token per profile, **no expiration**, **erased when the profile is claimed** (`claim_token = null`; the link then shows "already claimed").
7. **Source label:** `source = 'pilotcarloads.com'` (a competing company with a load board), `source_reference = 'AK listing · High Pole filter · 2026-09-14'`. Nash: "not sure if we should mention that" → the source is **internal only**: shown in the admin drawer and analytics, never in emails or on the claim page (the email says only "associated with pilot car services in {{state}}", §16).
8. **Follow-up email:** a **managed template** like the initial invitation — created, edited, previewed, activated by the moderator team, not fixed in code. Both `pilot_invitation_initial` and `pilot_invitation_follow_up` ship as editable code defaults; the campaign picks which follow-up template it uses (Task D3). Access: "the moderator, not only the admin" — today the codebase has no separate moderator role ("admin covers all for the MVP", `src/lib/domain/moderation.ts`); template management and the manager page are gated on the **internal** flag (`isInternalRole`), so a Moderator role inherits access the day it is added, with no further change.
9. **Page location:** its own page `/admin/pilot-invitations`, linked from the **admin user menu** next to Moderator Dashboard. **Not** in the testing bar.

All questions answered — the document is ready for implementation.

---

## Implementation record (2026-09-14)

| Module / task | Status | Where |
|---|---|---|
| A1–A7 data model | Done — migration `supabase/migrations/0018_pilot_network.sql` (**apply in the Supabase SQL editor**, then `node scripts/verify-migration-0018.mjs`). Tables: `pilot_capability_types` (7 positions + 5 certifications seeded), `pilot_profiles` (brief §5 columns + `claim_token` nullable/erased on claim, `unsubscribe_token`, `phone_digits`), `pilot_profile_capabilities` (`data_status` imported / user_confirmed / hha_reviewed / document_verified), `pilot_invitation_events`, `pilot_suppressions`, `pilot_campaigns`, `pilot_campaign_recipients`, `pilot_network_audit_log`, `pilot_network_settings` (cron switch), `email_templates` + `category`, `cta_text`, `footer`, `unsubscribe_text`; the two pilot templates inserted as rows (§16 wording verbatim; follow-up draft, editable). `import_runs` / `import_records` created if 0017 is not applied. | migration |
| A8 scripts | Done — `scripts/verify-migration-0018.mjs`; `scripts/seed-pilot-leads-ak-2026-09-14.mjs` (the four screenshot leads via the import path = import run #1; `--dry-run`); `scripts/import-pilot-leads.mjs file.csv --source=… [--commit]`; shared `scripts/lib/pilot-leads.mjs` (mirrors the TS rules — keep in step). | scripts |
| Domain rules | Done — `src/lib/domain/pilot-network.ts` (statuses §11, send blocks §19/§23, follow-up date, per-day key, duplicate order email → phone → name+company §28, state merge, capability keys, template rendering §15 incl. empty-company line rule, unresolved-variable check, analytics ratios); `src/lib/domain/pilot-leads-csv.ts` (CSV). Tests `tests/pilot-network.test.ts` (17). | domain |
| Data layer | Done — `src/lib/data/pilot-network.ts`: availability check, audit writer, profiles with filters/pagination, detail + history, summary (§9), analytics (§25), audit view, settings, templates, claim/unsubscribe links, rendering, `sendToProfile` (recorded), campaigns + recipient preview, `runPilotCampaignsDay` (daily limit per calendar day, one follow-up, skip rules, completion), `importPilotLeads` (dedupe/merge, new capability types inactive), `claimProfile`, `unsubscribeByToken`, `suppressProfile`. | data |
| Email seam (answer 1) | Done — `src/lib/adapters/email.ts`: one `sendEmail()`; today returns `recorded_not_delivered`; a provider plugs in later without touching callers. | adapter |
| B1–B3 templates | Done — "Email templates" tab in the manager: list, create, edit subject/body/CTA/footer/unsubscribe wording, activate/deactivate, save with version + reason, preview for a real lead or a sample without company, send test email (recorded). `/admin/email-templates` links to it. | `pilot-invitation-manager.tsx`, `api/admin/pilot-invitations/templates` |
| C1–C5 manager page | Done — `/admin/pilot-invitations` (internal gate; admin user menu link, not the testing bar): 8 summary cards, leads table (— for no company), search + state + capability + status filters in the URL, pagination, detail drawer with every §12 field and action (approve, send, preview, resend, mark duplicate / invalid / do-not-contact with suppression, edit incl. capabilities, add note, communication history). | `src/app/admin/pilot-invitations/`, `api/admin/pilot-invitations/profiles` |
| D1–D4 campaigns + cron | Done — Campaigns tab: create (name, state, contacts picked or "all approved in state", initial + follow-up template, daily limit with 10/25/50/100 presets or any integer, start date, follow-up days), preview recipients with skip reasons, start / pause / resume / stop, change daily limit; cron on/off switch + "Run today's batch"; scheduler route `POST /api/admin/pilot-invitations/run-day` with header `x-pilot-cron-secret` = env `PILOT_CRON_SECRET` (add to `.env`; not added to `.env.example` because that file has another session's uncommitted edits). | `api/admin/pilot-invitations/campaigns`, `settings`, `run-day` |
| E1–E4 claim + unsubscribe | Done — `/pilot/claim/[token]` (public): what we have (editable), states, What describes you?, capability confirm / remove / add (imported badge), opt-in unchecked; records claim, erases token, hands into `/signup/pilot` with details carried over. Real account creation: **not possible yet** — logins come from `AUTH_USERS`; the claim is fully recorded server-side and the wizard remains the replica (answer 4 fallback). `/pilot/unsubscribe/[token]` (public, one click, idempotent, suppression row). | `src/app/pilot/…`, `api/pilot/claim`, `api/pilot/unsubscribe` |
| F1 analytics | Done — Analytics tab (totals, rates, by state, by capability; bounce rate "—" until a provider exists). | manager |
| G1–G3 import | Done — Import tab (CSV, dry run then commit, per-row result) + CLI; unknown capabilities become inactive types. | `api/admin/pilot-invitations/import` |
| Answer 3 | Done — `Third car`, `Fourth car` added to the in-app `PILOT_CAPABILITIES`. | `src/lib/domain/pilot.ts` |
| Dev preview | `/dev-preview/admin/pilot-invitations` and `/dev-preview/admin/pilot-claim` render the tool with the four sample leads (layout check, dev only). | dev-preview |

Verification: `tsc` clean; eslint clean on every new file; vitest 198 pass (+ the pre-existing warnings failure); browser: manager renders cards, table, filters, drawer with all actions, every tab (campaign form, template editor with both seeded templates, analytics, audit, import); claim page renders the imported data, both account types, capability chips and an unchecked opt-in. Live database checks (send, campaign run, claim, unsubscribe) need migration 0018 applied and the seed run — do that next, then open `/admin/pilot-invitations`.

Not built (out of scope §29 / later phase): email delivery, bounce/complaint webhooks, SMS, real pilot account creation, marketplace features.

Follow-up (2026-09-14, after the preview): "WITPAC, CEVO, CSE, Passport, NY Certified — those are not types of pilot cars… those are certifications that the pilot cars activate on their profile. We don't show those on the pilot leads." → the leads filter, the drawer's capability editor and the claim page's "Add more" list show **escort positions only**. The five certification types stay seeded in `pilot_capability_types` (group `certification`) for the pilot profile later; nothing on the lead screens references them.

Follow-up (2026-09-15): Nash created a ZeptoMail agent "HeavyHaulAgent" for heavyhaulagent.com. **ZeptoMail provider built** behind the email seam (`src/lib/adapters/email.ts`, HTTP API `api.zeptomail.com/v1.1/email`). Environment only — no secret in code: `EMAIL_PROVIDER=zeptomail`, `ZEPTOMAIL_SEND_TOKEN` (Send Mail token; a token pasted into chat was treated as exposed and must be regenerated), `EMAIL_FROM`, `EMAIL_FROM_NAME`, `EMAIL_REPLY_TO`. Unset → recorded-only as before. A refused send is stored in the history as `failed` with ZeptoMail's error, the profile status does not move, and a campaign recipient stays pending for the next run. Only the pilot invitation pipeline sends. Open: verified domain confirmation, test recipient, bounce webhooks (need the production URL).

Follow-up (2026-09-15): **bounce webhook built**: `POST /api/pilot/email-webhook?key=<PILOT_WEBHOOK_SECRET>` (`src/app/api/pilot/email-webhook/route.ts`, parser `src/lib/domain/zeptomail-webhook.ts`). Hard bounce / complaint → suppression (`bounced` / `complaint`), status `invalid`, history + audit; soft bounce → history only. Add `PILOT_WEBHOOK_SECRET` to `.env` and paste the URL into the ZeptoMail agent's Webhooks tab (events: hard bounce, soft bounce, complaint). Bounce rate on the Analytics tab fills from these events.

Correction (2026-09-15, Nash): the workspace app runs on **heavyhaulgbt.com**, not heavyhaulagent.com. Webhook URL: `https://heavyhaulgbt.com/api/pilot/email-webhook` (header `x-pilot-webhook-secret`). Cron: `POST https://heavyhaulgbt.com/api/admin/pilot-invitations/run-day`. The server's `NEXT_PUBLIC_APP_URL` must be `https://heavyhaulgbt.com` so claim and unsubscribe links in emails point at the workspace. heavyhaulagent.com stays only as the ZeptoMail sending domain (`EMAIL_FROM`, verified there). Live check the same day: `/login` 200, dashboards 307 to login, webhook 403 without the secret and 200 with it — production runs the current code.
