# Freight Broker Leads — manager, campaigns, join flow

Written 2026-09-23 from Nash's brief: "these brokers are technically our leads that we can go and invite to start using our platform… we need something for the freight brokers where we can manage the leads, send them invites… create an email template for these campaigns, create campaigns and manage them so we can automate emails and schedule emails and put how many to go at the time."

Scope reminder: a lead is a **freight broker** and the **brokerage** they are pre-verified for. Nothing here creates or implies a carrier relation.

## Where things live

| Piece | Path |
|---|---|
| Rules (stages, segments, windows, ramp, steps, funnel) | `src/lib/domain/broker-leads.ts` · tests `tests/broker-leads.test.ts` |
| I/O (leads, segments, campaigns, sending, scheduler, analytics) | `src/lib/data/broker-leads.ts` |
| Hooks other modules call (webhook events, unsubscribe, signed up, confirmed) | `src/lib/data/broker-lead-hooks.ts` |
| Admin UI | `src/app/admin/broker-leads/` (page, manager, leads-tab, brokerages-tab, campaigns-tab, analytics-tab, settings-tab) |
| Admin API | `src/app/api/admin/broker-leads/{leads,segments,campaigns,run,settings}` |
| Join link (public) | `src/app/join/[token]/` + `src/app/api/broker-leads/join` |
| Templates | `broker_lead_invitation`, `broker_lead_reminder`, `broker_lead_final_notice` (updates stream, category "Freight broker invitations") |
| Migration | `supabase/migrations/0030_broker_leads_campaigns.sql` · `scripts/verify-migration-0030.mjs` · `scripts/backfill-broker-lead-fields.mjs` |
| Preload from the Synchron broker book | `scripts/synchron-brokers-prepare.mjs`, `scripts/fmcsa-safer-lookup.mjs`, `scripts/import-brokerages.mjs`, `scripts/import-broker-leads.mjs` (input folder `imports/…`, never committed) |

## Data model (0030)

- `broker_agent_leads` gains management columns: `state`, `mailbox_type` (person | role), `email_domain_check`, `source_system`, `source_last_seen_at`, `tags[]`, `notes`, suppression (`suppressed_at/reason/by`), counters (`emails_sent`, `last_emailed_at`, `last_template_key`, `last_delivered_at`, `last_opened_at`, `last_clicked_at`, `bounced_at`, `unsubscribed_at`, `reminded_at`), `signed_up_at`, `signed_up_user_id`, `join_token_issued_at`.
- `broker_lead_events` — the per-lead timeline (imported, edited, note, tagged, suppressed, enrolled, email_sent / skipped / failed, delivered, opened, clicked, bounced, complained, unsubscribed, join_link_opened, signed_up, confirmed, declined, stopped).
- `broker_lead_segments` — saved filter sets.
- `email_campaigns` (from 0028) gains `steps` (sequence), `audience_kind` + `audience`, `send_window`, `batch_size`, `ramp`, `frequency_cap_days`, enrolment / done / stopped / signed-up counters, `first_run_date`, `last_run_at`, `last_run_summary`, approval fields.
- `email_campaign_recipients` — one row per lead per campaign; `step` is the next step to send; status pending → scheduled → sent … done | stopped | skipped | failed | converted.
- `broker_leads_settings` — cron switch and defaults (single row).

## Lead stage (derived, never stored)

`converted` (claimed + confirmed_current) › `declined` › `manual_review` › `suppressed` › `unsubscribed` › `bounced` › `signed_up` › `clicked` › `opened` › `invited` › `not_invited`. Terminal states win over engagement. `leadSendBlock` refuses campaign email to converted, declined, review, suppressed, unsubscribed, bounced and signed-up leads; the central email policy (`preSendDecision`) still has the last word on suppression, hard bounces and preferences.

## Campaign lifecycle

draft → (enrol audience, test send, fix readiness problems) → **approve** → running ⇄ paused → stopped | completed.

Readiness: a name, at least one step with an existing active template, a start date, a daily limit ≥ 1, at least one enrolled recipient.

Editing a running or paused campaign changes schedule and throttle only; sequence and audience are fixed once people are enrolled. Duplicate to start over.

## Scheduler (`runBrokerCampaigns`)

Hourly `POST /api/admin/broker-leads/run` with header `x-campaign-cron-secret: $CAMPAIGN_CRON_SECRET` (falls back to `PILOT_CRON_SECRET`), or "Run now" in the UI (ignores the switch). For every running campaign inside its date range:

1. **Allowance today** = `min(daily_send_limit, ramp.start + ramp.step × sendingDay)` capped at `ramp.max`, minus what already went today; at most `batch_size` per pass.
2. **Due recipients**: pending or scheduled with `scheduled_for ≤ now`.
3. **Stop rules** (`leadSendBlock`) → recipient stopped or converted; timeline `stopped`.
4. **Send window** in the lead's time zone (majority zone per state; or a fixed zone) → otherwise rescheduled to the next open window.
5. **Frequency cap** across all campaigns (`last_emailed_at`) → otherwise rescheduled to the release time.
6. **Send** through `sendPlatformEmail` (updates stream, `campaign_type = broker_recruitment`, personal `join_link`) → next step scheduled at `sent + delay_days`, pushed into the window; last step → done.
7. Campaign counters and completion (no pending/scheduled left).

Repeated calls are safe: allowances are per calendar day, a step is never sent twice, failures stay pending for the next pass.

## Conversion tracking

- ZeptoMail webhooks (`src/lib/email/events.ts`) → `recordLeadEmailEvent` updates the lead's last-activity columns and timeline; campaign counters are already kept by the event processor.
- Preference centre / one-click unsubscribe → `markLeadUnsubscribed`.
- Join link sign-in, historical confirmation, Company Admin invitation → `markLeadSignedUp`, `markLeadDecision` (recipient → converted, campaign `conversion_count`, `signed_up_count`).

## Join flow

Campaign email → `/join/<token>` (30-day signed token bound to lead id + email) → name/phone prefilled → emailed code (`issueEmailCode` / `verifyEmailCode`) → self-service **freight broker** account + session → `/fb-dashboard?tab=company-info`, where the pre-verified relation waits to be confirmed (existing historical-record flow).

## Compliance

Updates stream only; unsubscribe + preference links on every email (added by the sender); suppression respected centrally; role mailboxes can be excluded from any audience; warm-up ramp and business-hours windows by default; frequency cap 7 days.

## Deferred

Subject-line A/B tests, per-lead time-zone from phone area code, SMS, CSV re-import of edited leads, campaign templates for carriers/pilots on the same engine (the tables are generic; only the lead source differs).
