# Marketing — Leads & Pipeline — Implementation Plan

**Spec:** docs/specs/2026-05-30-marketing-leads-pipeline.md  ·  **Slug:** marketing-leads-pipeline  ·  **Wave:** 7
**Depends on:** calendar-module, customers-module, foundation-auth-rbac, invoices-core, projects-module, system-communications-notifications

## Goal
Deliver lead capture, tracking, and conversion: a Kanban pipeline of leads (NEW→CONTACTED→QUALIFIED→PROPOSAL→WON/LOST) with drag-and-drop via fractional indexing, an embeddable white-label public lead form (Business+), inbound webhooks from Facebook/Google/Zapier/Make/generic sources (Business+), and a Won→Customer conversion flow. Stage changes emit outbound webhooks and in-app notifications; form/webhook ingestion emits `lead_captured` Analytics Engine events. Leads are indexed in global search.

## Architecture
- **Data** lives in five new tenant-scoped tables (`leads`, `lead_activities`, `lead_forms`, `lead_form_submissions`, `lead_webhooks`) in `@zync/db`. All access goes through `tenantQuery` repo functions — routes never touch raw Drizzle (`no-raw-drizzle-from-routes`).
- **Conversion** calls the existing `createCustomer` repo (writes `customers`) and project create path (writes `projects`), setting `leads.customer_id` in the same transaction; activity logged as `type='converted'`.
- **Outbound webhooks** (`lead.created`, `lead.stage_updated`, `lead.converted`) are enqueued via the upstream `webhook.deliver` delivery layer (gateway schema owned by white-label-api). **In-app notifications** (`lead.assigned`) use `createNotification` from `@zync/notifications`. **Emails** (form submission notify) use `sendEmail` (Resend) from the comms layer.
- **Public form** is rendered as an Astro + React island in `zync-www` at `/f/{slug}`, fetching config from `GET /api/forms/:slug/public` and posting to `POST /api/forms/:slug` — both unauthenticated, same-origin under `zync.is`. Form-page logo assets are served public-read from `STORAGE` (R2).
- **Webhook secrets** are AES-256-GCM encrypted at rest via `encryptSecret`/`decryptSecret` (`INTEGRATION_ENCRYPTION_KEY`), decrypted only at HMAC-verification time.
- Consumes upstream tables: `customers`, `customer_contacts`, `projects`, `users`, `tenants` (for `tenants.country_code`/owner email), `permissions`/`role_permissions` (new `marketing:*` perms). Consumes exports: `createCustomer`, `createDb`/`tenantQuery`/`systemQuery`, `authMiddleware`, `requirePermission`, `requireTier`, `requireModuleEnabled`, `rateLimit`, `createNotification`, `sendEmail`, `encryptSecret`/`decryptSecret`, `encodeCursor`/`decodeCursor`/`buildPaginated`/`clampLimit`, `timingSafeEqual`, `DataTable`/`VirtualList`/`Sheet`/`Badge`/`Avatar`/`EmptyState` UI.

## Tech Stack
- **Packages:** `@zync/db` (Drizzle schema + repos), `@zync/types` (shared Lead/Form/Webhook types).
- **API:** `apps/zync-api` (Hono on Cloudflare Workers) — authed `/api/leads*`, `/api/lead-forms*`, `/api/lead-webhooks*`, `/api/marketing/*`; public `/api/forms/:slug*`, `/api/webhooks/leads/:webhookId`.
- **App UI:** `apps/zync-app` (Vite + React) — `/marketing/*` routes (overview, pipeline, forms, webhooks).
- **Public form UI:** `apps/zync-www` (`zync-www`, Astro + React island) — `/f/{slug}`.
- **Bindings:** `RATE_LIMITER_LEAD_FORM` (CF native RateLimiter), `ANALYTICS_ENGINE` (write-only AE, already declared), `STORAGE` (R2 public-read for form logos), `QUEUE` (webhook delivery), Hyperdrive→Neon Postgres.
- **Libraries:** Drizzle ORM, Zod (route validation), TanStack Virtual (list virtualization), `@dnd-kit` or equivalent for Kanban drag.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A. Schema & types | 1, 2 | `packages/db/src/schema/marketing.ts`, `packages/types/src/marketing.ts`, migration | No (foundation for all) |
| B. Repos & permissions | 3, 4, 5 | `packages/db/src/repos/leads.ts`, `lead-forms.ts`, `lead-webhooks.ts`, seed | After A; 3/4/5 parallel |
| C. Authed API | 6, 7, 8, 9 | `apps/zync-api/src/routes/marketing/*` | After B; 6/7/8/9 parallel |
| D. Public ingestion | 10, 11 | `apps/zync-api/src/routes/public/forms.ts`, `webhooks-leads.ts` | After B; parallel with C |
| E. App UI | 12, 13, 14, 15 | `apps/zync-app/src/pages/marketing/*` | After C |
| F. Public form page | 16 | `apps/zync-www/src/pages/f/[slug].astro` + island | After D |
| G. Cross-cut wiring | 17, 18 | search adapter, notification registry, wrangler | After C/D |

## Tasks

### Task 1: Marketing schema (Drizzle tables + migration)
**Blocks:** 2, 3, 4, 5  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/marketing.ts`
- Modify: `packages/db/src/schema/index.ts` (export marketing tables)
- Create: `packages/db/migrations/<ts>_marketing_leads_pipeline.sql`
**Steps:**
- [ ] Define the five tables in Drizzle pg-core matching the DDL below verbatim (UUID PKs, UUID FKs, TIMESTAMPTZ, BOOLEAN, JSONB, TEXT+CHECK enums).
- [ ] `leads.contract_id` is a nullable UUID column with **no** FK constraint in this migration — the `contracts` table is built later (cycle-cut: pipeline core builds before contracts-esignature wires e-sign). Add a SQL comment noting the FK is added by contracts-esignature.
- [ ] Add indexes: pipeline board query, activity feed, submission lookup, webhook lookup.
- [ ] Add `UNIQUE (tenant_id, slug)` on `lead_forms`.
- [ ] Generate the SQL migration via Drizzle Kit and commit both the schema and the generated SQL.
**Schema / Interfaces:**
```sql
CREATE TABLE leads (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  name TEXT NOT NULL,
  email TEXT,
  phone TEXT,
  company TEXT,
  notes TEXT,
  stage TEXT NOT NULL DEFAULT 'NEW'
    CHECK (stage IN ('NEW','CONTACTED','QUALIFIED','PROPOSAL','WON','LOST')),
  stage_position NUMERIC NOT NULL,
  source TEXT NOT NULL DEFAULT 'manual'
    CHECK (source IN ('manual','form','webhook','facebook','google','linkedin','instagram','zapier','make','referral','cold_outreach')),
  source_metadata JSONB,
  assigned_to UUID REFERENCES users(id),
  customer_id UUID REFERENCES customers(id),
  contract_id UUID,                       -- FK to contracts(id) added later by contracts-esignature (cycle-cut)
  lost_reason TEXT,
  estimated_value NUMERIC,
  utm_source TEXT,
  utm_medium TEXT,
  utm_campaign TEXT,
  utm_content TEXT,
  utm_term TEXT,
  archived_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_leads_board ON leads(tenant_id, stage, stage_position) WHERE archived_at IS NULL;
CREATE INDEX idx_leads_assigned ON leads(tenant_id, assigned_to);
CREATE INDEX idx_leads_customer ON leads(tenant_id, customer_id);

CREATE TABLE lead_activities (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  lead_id UUID NOT NULL REFERENCES leads(id),
  user_id UUID REFERENCES users(id),      -- NULL for system-generated activity
  type TEXT NOT NULL
    CHECK (type IN ('note','email_sent','call_logged','stage_changed','form_submitted','webhook_received','converted','contract_linked')),
  content TEXT,
  metadata JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_lead_activities_feed ON lead_activities(tenant_id, lead_id, created_at DESC);
CREATE INDEX idx_lead_activities_recent ON lead_activities(tenant_id, created_at DESC);

CREATE TABLE lead_forms (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  name TEXT NOT NULL,
  slug TEXT NOT NULL,
  fields JSONB NOT NULL,                  -- FieldConfig[]
  redirect_url TEXT,
  notify_email TEXT,
  is_active BOOLEAN NOT NULL DEFAULT true,
  style JSONB,                            -- { primaryColor, logoR2Key, fontFamily }
  created_by UUID NOT NULL REFERENCES users(id),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, slug)
);

CREATE TABLE lead_form_submissions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  form_id UUID NOT NULL REFERENCES lead_forms(id),
  lead_id UUID NOT NULL REFERENCES leads(id),
  payload JSONB NOT NULL,
  ip TEXT,
  user_agent TEXT,
  referrer TEXT,
  utm_source TEXT,
  utm_medium TEXT,
  utm_campaign TEXT,
  utm_content TEXT,
  utm_term TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_lead_form_submissions_form ON lead_form_submissions(tenant_id, form_id, created_at DESC);

CREATE TABLE lead_webhooks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  name TEXT NOT NULL,
  source TEXT NOT NULL
    CHECK (source IN ('facebook','google','linkedin','instagram','zapier','make','generic')),
  secret TEXT NOT NULL,                   -- AES-256-GCM ciphertext (INTEGRATION_ENCRYPTION_KEY)
  field_mapping JSONB,
  is_active BOOLEAN NOT NULL DEFAULT true,
  last_received_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_lead_webhooks_tenant ON lead_webhooks(tenant_id);
```
**Acceptance:**
- [ ] `pnpm --filter @zync/db drizzle-kit generate` produces a migration with all five tables, all enums as CHECK constraints, `UNIQUE(tenant_id, slug)`, and the five indexes.
- [ ] No integer FKs; every `*_id` referencing a table is UUID. `contract_id` has no FK constraint and carries the explanatory comment.

### Task 2: Shared marketing types
**Blocks:** 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/marketing.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Export `LeadStage`, `LeadSource`, `LeadActivityType`, `Lead`, `LeadActivity`, `LeadForm`, `LeadFormSubmission`, `LeadWebhook`, `FieldConfig`, `FieldMapping`, `LeadListResponse`, `MarketingOverview` types.
- [ ] `FieldConfig`/`FieldMapping` transcribed exactly from spec.
**Schema / Interfaces:**
```ts
export type LeadStage = 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'PROPOSAL' | 'WON' | 'LOST';
export type LeadSource = 'manual' | 'form' | 'webhook' | 'facebook' | 'google' | 'linkedin'
  | 'instagram' | 'zapier' | 'make' | 'referral' | 'cold_outreach';
export type LeadActivityType = 'note' | 'email_sent' | 'call_logged' | 'stage_changed'
  | 'form_submitted' | 'webhook_received' | 'converted' | 'contract_linked';
export type WebhookSource = 'facebook' | 'google' | 'linkedin' | 'instagram' | 'zapier' | 'make' | 'generic';

export type FieldConfig = {
  id: string;
  type: 'text' | 'email' | 'phone' | 'textarea' | 'select' | 'checkbox';
  label: string;
  placeholder?: string;
  required: boolean;
  options?: string[]; // type='select' only
};
export type FieldMapping = { [jsonPath: string]: 'name' | 'email' | 'phone' | 'company' | 'notes' };

export interface Lead {
  id: string; tenantId: string; name: string; email: string | null; phone: string | null;
  company: string | null; notes: string | null; stage: LeadStage; stagePosition: number;
  source: LeadSource; sourceMetadata: Record<string, unknown> | null;
  assignedTo: string | null; customerId: string | null; contractId: string | null;
  lostReason: string | null; estimatedValue: number | null;
  utmSource: string | null; utmMedium: string | null; utmCampaign: string | null;
  utmContent: string | null; utmTerm: string | null;
  archivedAt: string | null; createdAt: string; updatedAt: string;
}
export interface LeadListResponse { items: Lead[]; nextCursor: string | null; total: number; }
export interface MarketingOverview {
  leadsThisMonth: { count: number; deltaPct: number };
  conversionRate: number; // WON / (WON+LOST) closed this month
  pipelineValue: number;  // sum estimated_value of non-LOST non-archived
  byStage: { stage: LeadStage; count: number }[];
  bySource: { source: string; count: number }[];
  recentActivity: LeadActivity[]; // last 10, newest first
}
```
**Acceptance:**
- [ ] Types compile and are importable as `@zync/types`.

### Task 3: Leads repo (`@zync/db`)
**Blocks:** 6, 9, 10, 11  ·  **Blocked by:** 1, 2
**Files:**
- Create: `packages/db/src/repos/leads.ts`
- Modify: `packages/db/src/repos/index.ts`
**Steps:**
- [ ] Implement `listLeads(db, tenantId, filters, cursor, limit)` — cursor-based pagination via `encodeCursor`/`decodeCursor`; `clampLimit(limit, 100)` (max 100 rows/request). Filters: stage[], source, assigned_to, archived, includeLost (excludes LOST unless true), date range. Return `{ items, nextCursor, total }`.
- [ ] `getLeadWithActivities(db, tenantId, id)` — lead + ordered activities.
- [ ] `createLead(db, tenantId, input)` — compute initial `stage_position` (append to NEW column, or supplied stage).
- [ ] `updateLead(db, tenantId, id, patch)` — touch `updated_at`.
- [ ] `moveLeadStage(db, tenantId, id, { toStage, position })` — **in one transaction**: update `stage`+`stage_position`, insert `lead_activities` (type='stage_changed', metadata `{from,to}`). Rebalance the target column when min gap < 0.001 (same fractional-index pattern as tasks-board-engine). Return `{ previousStage, newStage }`.
- [ ] `archiveLead(db, tenantId, id)` — set `archived_at = now()`.
- [ ] `addActivity(db, tenantId, leadId, { userId, type, content, metadata })`; `listActivities(db, tenantId, leadId, cursor, limit)`.
- [ ] `assignLead(db, tenantId, id, userId)` — set `assigned_to`, return whether assignee changed (caller fires notification).
- [ ] `convertLead(db, tenantId, id, { customerId, projectId })` — in one transaction set `customer_id`, insert activity type='converted' metadata `{customerId, projectId}`. Does NOT change stage (stays WON).
- [ ] `overviewMetrics(db, tenantId)` — aggregate queries for the overview widgets (leads this month + delta, conversion rate, pipeline value, by stage, by source, recent 10 activities).
- [ ] All queries scoped by `tenant_id`; use `tenantQuery`. No raw Drizzle leaks to routes.
**Acceptance:**
- [ ] `moveLeadStage` and `convertLead` run their writes in a single transaction (audit/activity atomic with the mutation).
- [ ] `listLeads` never returns > 100 rows; LOST excluded unless `includeLost`.

### Task 4: Lead forms repo (`@zync/db`)
**Blocks:** 7, 10, 16  ·  **Blocked by:** 1, 2
**Files:**
- Create: `packages/db/src/repos/lead-forms.ts`
- Modify: `packages/db/src/repos/index.ts`
**Steps:**
- [ ] `listForms(db, tenantId)` with submission counts.
- [ ] `getFormById(db, tenantId, id)`; `getFormBySlug(db, tenantId, slug)` (active-only variant for public path).
- [ ] `getPublicFormBySlug(db, tenantSlug, slug)` — joins `tenants` by slug, returns `{ name, fields, style, tenantName, tenantLogoUrl, redirectUrl, isActive }` for the unauthenticated renderer. Returns null if not found or `is_active=false`.
- [ ] `createForm`, `updateForm` (slug immutable if submissions exist — enforce by counting `lead_form_submissions`), `deleteForm` (only if zero submissions).
- [ ] `countSubmissions(db, tenantId, formId)`; `listSubmissions(db, tenantId, formId, cursor, limit)`.
- [ ] `recordSubmission(db, tenantId, { formId, leadId, payload, ip, userAgent, referrer, utm* })`.
**Acceptance:**
- [ ] `updateForm` rejects a slug change when submissions > 0; `deleteForm` rejects when submissions > 0.

### Task 5: Lead webhooks repo + permissions seed
**Blocks:** 8, 11  ·  **Blocked by:** 1, 2
**Files:**
- Create: `packages/db/src/repos/lead-webhooks.ts`
- Modify: `packages/db/src/seed/permissions.ts` (add marketing perms to `seedPermissions`/`seedSystemRoles`)
- Modify: `packages/db/src/repos/index.ts`
**Steps:**
- [ ] `listWebhooks(db, tenantId)`, `getWebhookById(db, id)` (system-scoped lookup by id for the public handler), `createWebhook` (encrypt secret with `encryptSecret`, return plaintext once), `updateWebhook` (name/field_mapping/is_active), `deleteWebhook`, `touchWebhookReceived(db, id)` (sets `last_received_at = now()`).
- [ ] `decryptWebhookSecret(row)` helper wrapping `decryptSecret` (`INTEGRATION_ENCRYPTION_KEY`).
- [ ] Add permissions `marketing:read`, `marketing:write` to `seedPermissions`; grant `marketing:read`+`marketing:write` to OWNER/ADMIN/MEMBER roles, **exclude CONTRACTOR** entirely (no marketing access).
**Schema / Interfaces:**
```ts
// new permission strings registered in seedPermissions
export const MARKETING_PERMISSIONS = ['marketing:read', 'marketing:write'] as const;
```
**Acceptance:**
- [ ] After seed, OWNER/ADMIN/MEMBER hold `marketing:*`; CONTRACTOR holds neither.
- [ ] `createWebhook` stores ciphertext (not plaintext) and returns the plaintext secret exactly once in its response.

### Task 6: Leads API routes (authed)
**Blocks:** 12, 13, 15  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/marketing/leads.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount under `authMiddleware` + `requireModuleEnabled('marketing')`)
**Steps:**
- [ ] All routes: `authMiddleware`, then `requireModuleEnabled('marketing')`, then per-route `requirePermission`. Validate bodies/queries with Zod (`require-zod-validation-in-routes`).
- [ ] `GET /api/leads` (`marketing:read`) — parse filters + `cursor`/`limit`; call `listLeads`; return `LeadListResponse`.
- [ ] `POST /api/leads` (`marketing:write`) — create (source defaults 'manual'); **no** `lead_captured` AE event for manual leads. If `assigned_to` set, fire `lead.assigned` notification. Emit `lead.created` outbound webhook.
- [ ] `GET /api/leads/:id` (`marketing:read`) — detail + activities.
- [ ] `PATCH /api/leads/:id` (`marketing:write`) — update fields/assignee/estimated_value/stage. On stage change use `moveLeadStage` then emit `lead.stage_updated` outbound webhook + notify assignee. On assignee change to a new user, fire `lead.assigned` notification.
- [ ] `DELETE /api/leads/:id` (`marketing:write`) — `archiveLead` (soft).
- [ ] `POST /api/leads/:id/convert` (`marketing:write` + `customers:write`) — body `{ customerData, projectData? }`: in one transaction call `createCustomer`, optionally create project, `convertLead`; emit `lead.converted` outbound webhook; return `{ customerId, projectId? }`.
- [ ] `GET /api/leads/:id/activities` (`marketing:read`) — paginated feed.
- [ ] `POST /api/leads/:id/activities` (`marketing:write`) — body `{ type:'note'|'call_logged', content, metadata? }`.
- [ ] `GET /api/marketing/overview` (`marketing:read`) — single response from `overviewMetrics`.
**Schema / Interfaces:**
```
GET    /api/marketing/overview
GET    /api/leads
POST   /api/leads
GET    /api/leads/:id
PATCH  /api/leads/:id
DELETE /api/leads/:id
POST   /api/leads/:id/convert        body { customerData, projectData? } → { customerId, projectId? }
GET    /api/leads/:id/activities
POST   /api/leads/:id/activities     body { type:'note'|'call_logged', content, metadata? }
```
Outbound webhook payloads (via `webhook.deliver`):
```
lead.created        { leadId, name, email, source, stage, tenantId }
lead.stage_updated  { leadId, name, previousStage, newStage, tenantId }
lead.converted      { leadId, customerId, projectId, tenantId }
```
**Acceptance:**
- [ ] Manual lead creation emits NO `lead_captured` AE event.
- [ ] Convert requires both `marketing:write` and `customers:write`; sets `customer_id` and leaves stage = 'WON'.
- [ ] Stage change via PATCH emits `lead.stage_updated` and notifies the assignee if set.

### Task 7: Lead forms API routes (authed, Business+)
**Blocks:** 14  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-api/src/routes/marketing/lead-forms.ts`
- Modify: `apps/zync-api/src/routes/index.ts`
**Steps:**
- [ ] Gate every route: `authMiddleware` + `requireModuleEnabled('marketing')` + `requirePermission('marketing:write')` (read uses `marketing:read`) + `requireTier('business')` (Business+; non-Business → 402/upgrade signal).
- [ ] `GET /api/lead-forms` — list with submission counts.
- [ ] `POST /api/lead-forms` — create; auto-generate kebab-case slug from name (editable), validate `fields` as `FieldConfig[]`; default `notify_email` to tenant owner email when omitted.
- [ ] `GET /api/lead-forms/:id` — detail + fields.
- [ ] `PATCH /api/lead-forms/:id` — update/toggle active/update style; reject slug change if submissions > 0.
- [ ] `DELETE /api/lead-forms/:id` — only if zero submissions.
- [ ] `GET /api/lead-forms/:id/submissions` — paginated.
- [ ] Logo upload: accept JPG/PNG/WEBP only (reject SVG), store to `STORAGE` (R2) public-read bucket, persist key in `style.logoR2Key`.
**Schema / Interfaces:**
```
GET    /api/lead-forms
POST   /api/lead-forms
GET    /api/lead-forms/:id
PATCH  /api/lead-forms/:id
DELETE /api/lead-forms/:id
GET    /api/lead-forms/:id/submissions
```
**Acceptance:**
- [ ] Non-Business tenant receives an upgrade/402 response on every `/api/lead-forms` route.
- [ ] SVG logo upload is rejected; only JPG/PNG/WEBP accepted.

### Task 8: Lead webhooks config API routes (authed, Business+)
**Blocks:** 14  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-api/src/routes/marketing/lead-webhooks.ts`
- Modify: `apps/zync-api/src/routes/index.ts`
**Steps:**
- [ ] Same gating as Task 7 (`requireTier('business')`).
- [ ] `GET /api/lead-webhooks` — list (never return decrypted secret).
- [ ] `POST /api/lead-webhooks` — create; encrypt secret via repo; **return plaintext secret once** in the create response only.
- [ ] `PATCH /api/lead-webhooks/:id` — update name/field_mapping/is_active.
- [ ] `DELETE /api/lead-webhooks/:id` — delete.
**Schema / Interfaces:**
```
GET    /api/lead-webhooks
POST   /api/lead-webhooks        → returns { ..., secret } (plaintext, once)
PATCH  /api/lead-webhooks/:id
DELETE /api/lead-webhooks/:id
```
**Acceptance:**
- [ ] List/detail/PATCH responses never include the plaintext or ciphertext secret; only POST returns plaintext once.

### Task 9: Convert-lead transaction & notification wiring
**Blocks:** 15  ·  **Blocked by:** 3, 6
**Files:**
- Modify: `apps/zync-api/src/routes/marketing/leads.ts`
- Create: `apps/zync-api/src/routes/marketing/lead-notifications.ts` (helper)
**Steps:**
- [ ] Implement `notifyLeadAssigned(env, { tenantId, assigneeUserId, leadId, leadName })` → `createNotification({ type:'lead.assigned', userId: assigneeUserId, tenantId, title:"You've been assigned lead: "+leadName, link:'/marketing/pipeline?lead='+leadId })`.
- [ ] Wire it into `POST /api/leads` and `PATCH /api/leads/:id` on assignee set/change.
- [ ] Implement `emitLeadWebhook(env, event, payload)` thin wrapper enqueuing via `webhook.deliver` for `lead.created`/`lead.stage_updated`/`lead.converted`.
**Acceptance:**
- [ ] Assigning a lead to user X produces a `lead.assigned` notification visible to X.

### Task 10: Public form submission + config endpoints (no auth)
**Blocks:** 16  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/public/forms.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount WITHOUT `authMiddleware`)
**Steps:**
- [ ] `GET /api/forms/:slug/public?tenant={tenantSlug}` — no auth; `getPublicFormBySlug`; 404 if missing/inactive; return `{ name, fields, style, tenantName, tenantLogoUrl }`. Set strict CSP + no-cookie headers.
- [ ] `POST /api/forms/:slug?tenant={tenantSlug}` — no auth handler implementing spec steps 1–12:
  1. Lookup active form by slug+tenantSlug; 404 if not found/inactive.
  2. Validate required fields per `fields` config (inline field errors, no reload — return 422 with per-field errors).
  3. Rate limit via `rateLimit` using `RATE_LIMITER_LEAD_FORM` (20/min, key = formId + IP).
  4. Extract UTM params from query string.
  5. Extract `referrer` from `Referer` header.
  5a. Extract optional `catalogShareId` from POST body (hidden field).
  6. Create `leads` (source='form', source_metadata `{ formId, formName, catalogShareId? }`).
  7. Create `lead_form_submissions` (all UTM fields + ip/user_agent/referrer).
  8. Add `lead_activities` (type='form_submitted').
  9. Emit `lead.created` outbound webhook.
  10. Emit AE event `lead_captured` `{ tenantId, source:'form', formId, leadId, catalogShareId?, utmSource, utmMedium, utmCampaign }` via `ANALYTICS_ENGINE`.
  11. `sendEmail` to `notify_email` (Resend) — template: lead name, email, form name, link to pipeline; pass tenant `locale`.
  12. Return `{ ok:true, redirectUrl }` or `{ ok:true }`.
**Acceptance:**
- [ ] Submission with a missing required field returns 422 with per-field errors and creates no lead.
- [ ] 21st submission within a minute from same form+IP is rate-limited.
- [ ] `lead_captured` AE event is emitted on success with `source:'form'`.

### Task 11: Inbound lead webhook endpoint (no auth, HMAC)
**Blocks:** —  ·  **Blocked by:** 3, 5
**Files:**
- Create: `apps/zync-api/src/routes/public/webhooks-leads.ts`
- Create: `apps/zync-api/src/lib/lead-webhook-verify.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount WITHOUT `authMiddleware`)
**Steps:**
- [ ] `GET /api/webhooks/leads/:webhookId` — Facebook challenge: decrypt secret, compare `hub.verify_token` against decrypted secret using `timingSafeEqual`; return plaintext `hub.challenge` (200).
- [ ] `POST /api/webhooks/leads/:webhookId` — implement spec steps 1–9:
  1. Lookup webhook by id; 404 if missing/inactive.
  2. Verify HMAC per source (decrypt secret first); 403 on mismatch:
     - **facebook**: SHA-256 HMAC of raw body vs `X-Hub-Signature-256`, compared with `timingSafeEqual`.
     - **google**: compare body `google_key` vs decrypted secret via `timingSafeEqual` (no header).
     - **zapier/make/generic**: if secret non-empty, verify `X-Zync-Signature` HMAC; else accept unsigned.
  3. `touchWebhookReceived`.
  4. Apply `field_mapping` (JSON-path extraction) to map payload → leads columns. Per-source default mappings: facebook `full_name→name,email→email,phone_number→phone`; google `FULL_NAME→name,EMAIL→email,PHONE_NUMBER→phone`.
  5. Create `leads` (source from config, source_metadata = raw payload). If mapped `name` empty → name='(unknown)' (or '(from webhook)' for unmapped generic) + warning in metadata.
  6. Add `lead_activities` (type='webhook_received', metadata=payload).
  7. Emit `lead.created` outbound webhook.
  8. Emit AE event `lead_captured` `{ tenantId, source, webhookId, leadId, utmSource, utmMedium, utmCampaign }` (no `catalogShareId`).
  9. Return 200 `{ ok:true }`.
**Schema / Interfaces:**
```ts
// lead-webhook-verify.ts
export function verifyFacebookSignature(rawBody: string, header: string, secret: string): boolean;
export function verifyGoogleKey(bodyKey: string, secret: string): boolean;      // timing-safe
export function verifyGenericSignature(rawBody: string, header: string|null, secret: string): boolean;
export function applyFieldMapping(payload: unknown, mapping: FieldMapping, source: WebhookSource):
  { name: string; email?: string; phone?: string; company?: string; notes?: string };
```
**Acceptance:**
- [ ] All secret/signature comparisons use `timingSafeEqual` (`no-string-equality-for-tokens`).
- [ ] Facebook GET challenge returns the raw `hub.challenge` plaintext on token match, 403 otherwise.
- [ ] A webhook lead with empty mapped name still creates a lead named '(unknown)'.

### Task 12: Pipeline Kanban page
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/pages/marketing/PipelinePage.tsx`
- Create: `apps/zync-app/src/components/marketing/LeadCard.tsx`, `KanbanColumn.tsx`
- Create: `apps/zync-app/src/hooks/useLeads.ts`
**Steps:**
- [ ] Five visible columns (NEW…WON); LOST column hidden behind "Include lost" toggle, muted styling when shown.
- [ ] Lead card: name, company, stage age, assignee `Avatar`, estimated value, source icon. Use design tokens only (`no-hardcoded-colors`/`no-hardcoded-spacing`).
- [ ] Drag-and-drop between columns; on drop compute new fractional `stage_position` and PATCH `/api/leads/:id` with `{ stage, stage_position }`; optimistic update. Honor `prefers-reduced-motion` (disable drag transitions).
- [ ] Filter bar URL-synced: stage (multi), source, assigned_to, date range, include-lost toggle.
- [ ] View toggle Kanban | List persisted in `localStorage`.
- [ ] Columns/cards have correct ARIA roles (listbox/option or grid semantics) and keyboard reordering fallback.
**Acceptance:**
- [ ] Dragging a card to another column persists stage and survives reload.
- [ ] LOST hidden until toggle; filters reflected in URL.

### Task 13: List view + lead detail side-panel
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/pages/marketing/LeadListView.tsx`
- Create: `apps/zync-app/src/components/marketing/LeadDetailPanel.tsx`
**Steps:**
- [ ] List uses `DataTable`; columns Name, Company, Stage, Source, Assigned, Value, Stage age, Created. Sortable by Name/Stage/Created/Value.
- [ ] Cursor pagination (`GET /api/leads?cursor&limit=50`); when > 200 rows activate `VirtualList` (TanStack Virtual, row 64px, overscan 5).
- [ ] Bulk actions (assign/move-stage/archive) delegate to bulk-operations spec, not the list endpoint.
- [ ] Side-panel (`Sheet`) opens on card/row click with Info / Activity tabs and stage-action header (Move to next, Mark Won, Mark Lost with `lost_reason` prompt). Info: inline-editable name/email/phone/company, source badge + expandable `source_metadata`, collapsible editable UTM fields, estimated value, assignee picker, contract link (from `contract_id`).
- [ ] Activity tab: chronological `lead_activities` feed; add note (textarea), log call (duration + outcome), send email (compose sheet → logged as `email_sent`).
**Acceptance:**
- [ ] List virtualizes past 200 rows; cursor paging fetches next page.
- [ ] Mark Lost prompts for reason and sets stage=LOST.

### Task 14: Forms & webhooks management UI (Business+)
**Blocks:** —  ·  **Blocked by:** 7, 8
**Files:**
- Create: `apps/zync-app/src/pages/marketing/FormsPage.tsx`, `FormBuilder.tsx`
- Create: `apps/zync-app/src/pages/marketing/WebhooksPage.tsx`
**Steps:**
- [ ] Forms list table (Name, Slug, Active toggle, Submissions count, Created). Non-Business tenants see upgrade prompt via tier gate (`useTierGate`/`useUpgradeModal`).
- [ ] Form builder: step-by-step field editor (text/email/phone/textarea/select/checkbox; per-field label/placeholder/required; select options); style (logo upload JPG/PNG/WEBP, primary color hex, font family system stack); redirect URL; notify email; live preview pane. Slug auto kebab-case, editable, immutable after first submission (UI warns/blocks).
- [ ] Embed code panel: iframe snippet + direct link `https://zync.is/f/{slug}?tenant={tenantSlug}`, copy-to-clipboard each.
- [ ] Webhooks list (Name, Source, Active toggle, Last received). Create flow shows the plaintext secret once (copyable, warned it won't be shown again) + field-mapping editor.
**Acceptance:**
- [ ] Slug field is locked once submissions > 0.
- [ ] Webhook secret displayed exactly once on creation.

### Task 15: Won → Convert-to-Customer flow + overview page
**Blocks:** —  ·  **Blocked by:** 6, 9
**Files:**
- Create: `apps/zync-app/src/components/marketing/ConvertLeadSheet.tsx`
- Create: `apps/zync-app/src/pages/marketing/OverviewPage.tsx`
- Modify: `apps/zync-app/src/router.tsx` (`/marketing` → redirect `/marketing/overview`)
**Steps:**
- [ ] Convert CTA appears when stage='WON' and `customer_id` null. Sheet prefills customer name (company ?: name), email, phone; optional linked-project step (name + type). Submit → `POST /api/leads/:id/convert`. After success replace CTA with "View Customer →" link to `/customers/:id`.
- [ ] Overview page consumes `GET /api/marketing/overview`: Leads-this-month (+delta), conversion rate, pipeline value, leads-by-stage bar, leads-by-source pie, recent-activity (last 10). `/marketing` redirects to `/marketing/overview`.
**Acceptance:**
- [ ] Converting a WON lead creates a customer, optionally a project, and swaps the CTA to the customer link.
- [ ] Overview renders all six widgets from one request.

### Task 16: Public form page in zync-www (Astro island)
**Blocks:** —  ·  **Blocked by:** 4, 10
**Files:**
- Create: `apps/zync-www/src/pages/f/[slug].astro`
- Create: `apps/zync-www/src/components/LeadFormIsland.tsx`
**Steps:**
- [ ] `[slug].astro` reads `tenant` query param, server-fetches `GET /api/forms/:slug/public?tenant=…`; 404 layout if missing/inactive. Renders the React island (`client:load`).
- [ ] Island renders fields from config, styled via `style` (primary color, logo, font); mobile-first responsive; no cookies/session.
- [ ] On submit POST `POST /api/forms/:slug?tenant=…` same-origin; on success redirect to `redirect_url` or show inline "Thanks! We'll be in touch."; on 422 show inline per-field errors without reload.
- [ ] "Powered by Zync" footer shown except for Enterprise tenant with custom domain.
- [ ] Strict CSP, no inline secrets; reduced-motion respected; form controls labeled/aria-correct for a11y.
**Acceptance:**
- [ ] Form renders unauthenticated at `zync.is/f/{slug}?tenant=…`, submits same-origin, and shows inline success or redirects.

### Task 17: Global search adapter + notification type registration
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/search/leads-search.ts`
- Modify: notification type registry (system-communications-notifications) to register `lead.assigned`
**Steps:**
- [ ] Register leads in global search (spec 37): searchable `name`, `email`, `company`; result card shows name, company, stage badge, assigned user. Require `marketing:read`; exclude CONTRACTOR.
- [ ] Add `lead.assigned` to the notification type registry consumed by `createNotification`.
**Acceptance:**
- [ ] Searching a lead name returns it for a `marketing:read` user and never for CONTRACTOR.
- [ ] `lead.assigned` is a recognized notification type.

### Task 18: Foundation deltas — rate limiter binding & wrangler config
**Blocks:** 10  ·  **Blocked by:** —
**Files:**
- Modify: `apps/zync-api/wrangler.toml`
- Modify: `apps/zync-api/src/env.d.ts` (or `Env` type)
**Steps:**
- [ ] Add `RATE_LIMITER_LEAD_FORM` CF native RateLimiter binding (20 requests / 60s), following the `RATE_LIMITER_EXPENSE_UPLOAD` pattern.
- [ ] Confirm `ANALYTICS_ENGINE`, `STORAGE`, `QUEUE` bindings present (already declared upstream); add the binding to the `Env` type.
- [ ] No new Cloudflare secrets; reuse `INTEGRATION_ENCRYPTION_KEY` for `lead_webhooks.secret` and `RESEND_API_KEY` for notify emails.
**Acceptance:**
- [ ] `RATE_LIMITER_LEAD_FORM` resolves in the form submission handler; `pnpm --filter @zync/api typecheck` passes with the binding typed.
