{
  "slug": "marketing-leads-pipeline",
  "spec_file": "docs/specs/2026-05-30-marketing-leads-pipeline.md",
  "findings": [
    {
      "id": "marketing-leads-pipeline-001",
      "severity": "P0",
      "type": "broken",
      "summary": "POST /api/forms and POST /api/webhooks/leads never mounted — only /f router registered",
      "spec_ref": "API POST /api/forms/:slug; POST /api/webhooks/leads/:webhookId; GET Facebook challenge",
      "code_ref": "apps/zync-api/src/index.ts:98",
      "evidence": "grep publicFormRoutes|api/forms|webhooks/leads in apps/zync-api/src: only app.route('/f', publicFormRoutes) at index.ts:98. public-form.ts comments claim /api/forms and /api/webhooks/leads mounts but no matching app.route calls.",
      "repro": "POST https://api…/api/forms/{slug}/submit?tenant=… → 404. POST /api/webhooks/leads/{id} → 404. Catalog EmbeddedLeadForm posts to /api/forms/…/submit (CatalogView.tsx:254)."
    },
    {
      "id": "marketing-leads-pipeline-002",
      "severity": "P0",
      "type": "broken",
      "summary": "Public form tenant slug never resolved to tenant_id — lookup always uses raw ?tenant= string",
      "spec_ref": "Public Form Page /f/{slug}?tenant={tenantSlug}; Form handler step 1 slug+tenant_slug lookup",
      "code_ref": "apps/zync-api/src/routes/marketing/public-form.ts:134",
      "evidence": "const tenantId = uuidRe.test(tenantParam) ? tenantParam : tenantParam assigns tenantParam in both branches. getLeadFormBySlug filters eq(leadForms.tenantId, tenantId) (marketing.ts:731) — slug string never matches UUID tenant_id column.",
      "repro": "GET /f/contact?tenant=acme-corp (slug embed URL from forms.ts:145) → 404 Form not found even when form exists."
    },
    {
      "id": "marketing-leads-pipeline-003",
      "severity": "P0",
      "type": "security",
      "summary": "Convert lead endpoint missing required customers:write permission",
      "spec_ref": "Permissions — Convert lead to customer: marketing:write + customers:write",
      "code_ref": "apps/zync-api/src/routes/marketing/leads.ts:347",
      "evidence": "POST /:id/convert uses requirePermission('marketing:write') only (line 349). grep customers:write in apps/zync-api/src/routes/marketing/ → no matches.",
      "repro": "Session with marketing:write but without customers:write → POST /api/marketing/leads/:id/convert returns 200 and creates customer."
    },
    {
      "id": "marketing-leads-pipeline-004",
      "severity": "P0",
      "type": "broken",
      "summary": "Catalog embedded form POST body shape mismatches API schema (fields vs payload)",
      "spec_ref": "Form Submission Handler — validate fields; POST /api/forms/:slug",
      "code_ref": "apps/zync-www/src/components/catalog/CatalogView.tsx:258",
      "evidence": "EmbeddedLeadForm sends JSON.stringify({ fields: values }). publicFormSubmitSchema requires { payload: Record<uuid, unknown> } (validation/marketing.ts:201-203). Field keys use field.name not field.id (CatalogView.tsx:308-324).",
      "repro": "Submit catalog embedded lead form → 400 Validation failed even if /api/forms route were mounted."
    },
    {
      "id": "marketing-leads-pipeline-005",
      "severity": "P1",
      "type": "missing",
      "summary": "zync-www standalone public form page /f/{slug} not implemented",
      "spec_ref": "Public Form Page /f/{slug}?tenant={tenantSlug} in zync-www Astro",
      "code_ref": "apps/zync-api/src/index.ts:98",
      "evidence": "glob apps/zync-www/src/pages/f/** → 0 files. Only catalog EmbeddedLeadForm exists (CatalogView.tsx:240). No [slug].astro per plan Task 16.",
      "repro": "Navigate to https://zync.is/f/{slug}?tenant={slug} → no Astro page; API config only at GET /f/:slug on worker."
    },
    {
      "id": "marketing-leads-pipeline-006",
      "severity": "P1",
      "type": "missing",
      "summary": "/marketing/webhooks management UI route absent",
      "spec_ref": "Inbound Webhooks /marketing/webhooks; Plan Task 14 WebhooksPage",
      "code_ref": "apps/zync-app/src/modules/marketing.tsx:28",
      "evidence": "marketing.tsx Routes list overview, pipeline, forms, leads — no webhooks path. glob apps/zync-app/src/routes/marketing/webhooks* → 0 files. Settings webhooks at /settings/integrations/webhooks use outbound /api/webhooks/endpoints (WebhookListPage api.ts:59), not lead_webhooks.",
      "repro": "Open /marketing/webhooks → no route. Inbound lead webhook config only reachable via API /api/marketing/webhooks."
    },
    {
      "id": "marketing-leads-pipeline-007",
      "severity": "P1",
      "type": "missing",
      "summary": "Pipeline list view, cursor pagination, virtual scroll, and bulk actions not implemented",
      "spec_ref": "List View table + Virtual scroll >200 rows; Bulk actions assign/move/archive",
      "code_ref": "apps/zync-app/src/routes/marketing/pipeline.tsx:459",
      "evidence": "grep VirtualList|list view|viewMode|bulk in apps/zync-app/src/routes/marketing/ → no matches. pipeline.tsx renders Kanban only; no DataTable or GET /api/marketing/leads cursor paging.",
      "repro": "Pipeline page has no Kanban|List toggle; no bulk assign/archive UI."
    },
    {
      "id": "marketing-leads-pipeline-008",
      "severity": "P1",
      "type": "missing",
      "summary": "lead.assigned in-app notification never implemented",
      "spec_ref": "Notifications — lead.assigned on assigned_to change; Plan Task 9",
      "code_ref": "apps/zync-api/src/routes/marketing/leads.ts:204",
      "evidence": "grep createNotification|lead.assigned in apps/zync-api/src/routes/marketing/ → no matches. lead.assigned absent from packages/types/src/notification-type.ts (lines 10-58).",
      "repro": "PATCH /api/marketing/leads/:id { assigned_to: userId } → no notification row type lead.assigned."
    },
    {
      "id": "marketing-leads-pipeline-009",
      "severity": "P1",
      "type": "missing",
      "summary": "lead.created outbound webhook not emitted on manual/form/webhook ingestion",
      "spec_ref": "Outbound Webhook Events lead.created; Form handler step 9; Inbound webhook step 7",
      "code_ref": "apps/zync-api/src/routes/marketing/leads.ts:217",
      "evidence": "grep lead.created|webhook.deliver in apps/zync-api/src/routes/marketing/ → only lead.stage_updated in leads.ts:287-291. public-form.ts form POST and webhook POST have no QUEUE.send for lead.created.",
      "repro": "Create lead manually or via form/webhook → no lead.created delivery enqueued."
    },
    {
      "id": "marketing-leads-pipeline-010",
      "severity": "P1",
      "type": "missing",
      "summary": "lead.converted outbound webhook not emitted after convert",
      "spec_ref": "Outbound Webhook Events lead.converted; Plan Task 6 convert flow",
      "code_ref": "apps/zync-api/src/routes/marketing/leads.ts:370",
      "evidence": "convert handler calls convertLeadToCustomer and returns 200 with no QUEUE.send. grep lead.converted in apps/zync-api/ → no route enqueue.",
      "repro": "POST /api/marketing/leads/:id/convert on WON lead → customer created, no lead.converted webhook."
    },
    {
      "id": "marketing-leads-pipeline-011",
      "severity": "P1",
      "type": "missing",
      "summary": "Form submission notification email (Resend) not sent",
      "spec_ref": "Form Submission Handler step 11 — sendEmail to notify_email",
      "code_ref": "apps/zync-api/src/routes/marketing/public-form.ts:197",
      "evidence": "grep sendEmail in apps/zync-api/src/routes/marketing/ → no matches. Handler ends at AE writeDataPoint and JSON response (lines 205-227).",
      "repro": "Successful form submission creates lead but sends no owner notification email."
    },
    {
      "id": "marketing-leads-pipeline-012",
      "severity": "P1",
      "type": "missing",
      "summary": "POST create lead-webhook does not return plaintext secret once in response",
      "spec_ref": "API POST /api/lead-webhooks returns secret once; Plan Task 8 acceptance",
      "code_ref": "apps/zync-api/src/routes/marketing/webhooks.ts:103",
      "evidence": "POST returns c.json({ webhook }, 201). serializeWebhook omits secret (marketing.ts:222-228). Client must supply secret in request body (createLeadWebhookSchema.secret required).",
      "repro": "POST /api/marketing/webhooks { name, source, secret } → response webhook object has no secret field."
    },
    {
      "id": "marketing-leads-pipeline-013",
      "severity": "P1",
      "type": "missing",
      "summary": "GET lead-form submissions endpoint is stub — no paginated list",
      "spec_ref": "API GET /api/lead-forms/:id/submissions paginated",
      "code_ref": "apps/zync-api/src/routes/marketing/forms.ts:168",
      "evidence": "Handler returns { formId, total: form.submissionCount } only. grep listSubmissions in packages/db/src/queries/ → no matches.",
      "repro": "GET /api/marketing/forms/:id/submissions → { formId, total } with no items/cursor."
    },
    {
      "id": "marketing-leads-pipeline-014",
      "severity": "P1",
      "type": "missing",
      "summary": "Leads excluded from live global search (no search_vector + not in LIVE_ENTITY_TYPES)",
      "spec_ref": "Global Search — leads indexed searchable name/email/company",
      "code_ref": "packages/db/src/search/search-service.ts:93",
      "evidence": "LIVE_ENTITY_TYPES Set (lines 93-100) omits 'lead'. searchLeads queries l.search_vector (entity-queries.ts:414) but grep search_vector in packages/db/migrations/*lead* and *marketing* → no matches on leads table.",
      "repro": "Global search query matching lead name → lead group never returned."
    },
    {
      "id": "marketing-leads-pipeline-015",
      "severity": "P1",
      "type": "missing",
      "summary": "MEMBER system role lacks marketing:read/marketing:write per plan",
      "spec_ref": "Plan Task 5 — grant marketing:* to OWNER/ADMIN/MEMBER; exclude CONTRACTOR",
      "code_ref": "packages/db/src/seed/permission-keys.ts:71",
      "evidence": "SYSTEM_ROLE_PERMISSIONS.MEMBER = tasks/projects/time/kb only — no marketing keys. CONTRACTOR correctly excluded (line 73).",
      "repro": "User with MEMBER role → 403 on all /api/marketing/* despite spec implying core CRM pipeline access."
    },
    {
      "id": "marketing-leads-pipeline-016",
      "severity": "P1",
      "type": "missing",
      "summary": "deleteLeadForm allows delete when submissions exist",
      "spec_ref": "API DELETE /api/lead-forms/:id only if no submissions; Plan Task 4",
      "code_ref": "packages/db/src/queries/marketing.ts:803",
      "evidence": "deleteLeadForm executes DELETE WHERE tenant_id AND id with no submission count check. forms.ts:118-128 route only checks form exists.",
      "repro": "DELETE /api/marketing/forms/:id on form with submissions > 0 → 200 ok; submissions orphaned."
    },
    {
      "id": "marketing-leads-pipeline-017",
      "severity": "P1",
      "type": "missing",
      "summary": "Convert flow ignores optional project_data — no linked project created",
      "spec_ref": "Won Lead → Convert — optional POST /api/projects; body projectData",
      "code_ref": "apps/zync-api/src/routes/marketing/leads.ts:370",
      "evidence": "convertLeadSchema includes project_data (validation/marketing.ts:129-134) but convert handler only passes customer_data to convertLeadToCustomer. lead-conversion.ts creates customer only.",
      "repro": "POST convert with project_data → 200 { customerId } only; no projectId."
    },
    {
      "id": "marketing-leads-pipeline-018",
      "severity": "P1",
      "type": "missing",
      "summary": "Pipeline side-panel missing spec stage actions and activity compose flows",
      "spec_ref": "Lead Detail — Move to next, Mark Won/Lost, add note, log call, send email",
      "code_ref": "apps/zync-app/src/routes/marketing/pipeline.tsx:373",
      "evidence": "LeadDetailPanel shows read-only metadata + activity list; no Mark Won/Lost, note textarea, call log, or email compose. grep Mark Lost|add note in pipeline.tsx → no matches. LeadInfoPanel has Mark Lost (leads.$id route only).",
      "repro": "Click Kanban card → sheet lacks Mark Lost/Won and manual activity creation."
    },
    {
      "id": "marketing-leads-pipeline-019",
      "severity": "P2",
      "type": "divergent",
      "summary": "Authed API paths under /api/marketing/* not spec /api/leads and /api/lead-forms",
      "spec_ref": "API Endpoints GET/POST /api/leads, /api/lead-forms, /api/lead-webhooks",
      "code_ref": "apps/zync-api/src/routes/index.ts:315",
      "evidence": "routes.route('/marketing', marketingRouter). leads mounted at marketing/leads.ts; forms at marketing/forms.ts. Spec documents /api/leads; app uses /api/marketing/leads (pipeline.tsx:473).",
      "repro": "Integrations expecting GET /api/leads → 404; must use /api/marketing/leads."
    },
    {
      "id": "marketing-leads-pipeline-020",
      "severity": "P2",
      "type": "divergent",
      "summary": "Drag-drop move endpoint does not emit lead.stage_updated webhook or assignee notification",
      "spec_ref": "Stage change — emit lead.stage_updated; notify assignee if assigned_to set",
      "code_ref": "apps/zync-api/src/routes/marketing/leads.ts:319",
      "evidence": "PATCH /:id/move calls moveLead + addLeadActivity only. lead.stage_updated QUEUE.send exists only on PATCH /:id when input.stage set (lines 267-294), not on /move.",
      "repro": "Kanban drag-drop (PATCH …/move) → activity logged but no outbound lead.stage_updated webhook."
    },
    {
      "id": "marketing-leads-pipeline-021",
      "severity": "P2",
      "type": "divergent",
      "summary": "Stage change activity not atomic with lead update in single transaction",
      "spec_ref": "Drag stage change — update stage+position and log activity in same transaction",
      "code_ref": "apps/zync-api/src/routes/marketing/leads.ts:264",
      "evidence": "updateLead and moveLead run separate UPDATE (marketing.ts:533-561). addLeadActivity is separate INSERT afterward in route (leads.ts:268-275, 322-325). moveLead repo does not insert stage_changed activity.",
      "repro": "Crash between updateLead and addLeadActivity → stage changed with no activity row."
    },
    {
      "id": "marketing-leads-pipeline-022",
      "severity": "P2",
      "type": "divergent",
      "summary": "RATE_LIMITER_LEAD_FORM configured 3/min not spec 20/min; key uses slug not form id",
      "spec_ref": "Foundation Deltas RATE_LIMITER_LEAD_FORM 20/min keyed by form id + IP",
      "code_ref": "apps/zync-api/wrangler.toml:115",
      "evidence": "wrangler.toml limit=3 period=60 for RATE_LIMITER_LEAD_FORM. public-form.ts:167 key `form:${slug}:${ip}` not formId.",
      "repro": "4th submission/min from same IP allowed only after 60s at limit 3; spec expects 20."
    },
    {
      "id": "marketing-leads-pipeline-023",
      "severity": "P2",
      "type": "divergent",
      "summary": "Form-created leads omit source_metadata { formId, formName, catalogShareId }",
      "spec_ref": "Form handler step 6 source_metadata; catalogShareId hidden field",
      "code_ref": "packages/db/src/queries/marketing.ts:858",
      "evidence": "createFormSubmissionAndLead insert leads sets source='form' but no sourceMetadata field. publicFormSubmitSchema has no catalogShareId (validation/marketing.ts:201-209).",
      "repro": "Form submission lead row has source_metadata NULL; catalog funnel cannot filter catalogShareId."
    },
    {
      "id": "marketing-leads-pipeline-024",
      "severity": "P2",
      "type": "divergent",
      "summary": "Inbound webhook handler lacks per-source default field mappings",
      "spec_ref": "Facebook/Google default mappings full_name→name etc.",
      "code_ref": "packages/db/src/queries/marketing.ts:1028",
      "evidence": "createWebhookLead only applies webhook.fieldMapping JSON paths. Empty mapping → name '(unknown)' with no Facebook/Google defaults.",
      "repro": "Facebook webhook with empty field_mapping → lead name '(unknown)' despite standard payload."
    },
    {
      "id": "marketing-leads-pipeline-025",
      "severity": "P2",
      "type": "divergent",
      "summary": "Generic/Zapier webhooks require signature even when spec allows unsigned",
      "spec_ref": "Zapier/Make/Generic — optional HMAC if secret non-empty else accept unsigned",
      "code_ref": "apps/zync-api/src/routes/marketing/public-form.ts:104",
      "evidence": "verifyWebhookSignature for zapier/make/generic returns false when X-Zync-Signature missing (lines 106-109). No empty-secret bypass branch.",
      "repro": "Webhook with blank secret config still 403 without X-Zync-Signature header."
    },
    {
      "id": "marketing-leads-pipeline-026",
      "severity": "P2",
      "type": "divergent",
      "summary": "Facebook verification GET path is /f/:id/verify not /api/webhooks/leads/:id",
      "spec_ref": "GET /api/webhooks/leads/{webhookId}?hub.mode=subscribe…",
      "code_ref": "apps/zync-api/src/routes/marketing/public-form.ts:234",
      "evidence": "Route defined as publicFormRoutes.get('/:webhookId/verify') mounted at /f only → GET /f/{uuid}/verify. Spec path /api/webhooks/leads/{id} without /verify suffix.",
      "repro": "Facebook app callback URL /api/webhooks/leads/{id} → 404; must use non-spec /f/{id}/verify."
    },
    {
      "id": "marketing-leads-pipeline-027",
      "severity": "P2",
      "type": "divergent",
      "summary": "Overview widget missing vs-last-month delta display",
      "spec_ref": "Overview — Leads this month count vs last month delta",
      "code_ref": "apps/zync-app/src/routes/marketing/overview.tsx:251",
      "evidence": "API returns leadsThisMonth and leadsLastMonth (marketing.ts:1172-1173). UI computes _monthDelta (overview.tsx:251) but StatCard shows count only (lines 262-266), no deltaPct.",
      "repro": "Overview page shows Leads This Month number without month-over-month change."
    },
    {
      "id": "marketing-leads-pipeline-028",
      "severity": "P2",
      "type": "divergent",
      "summary": "pipeline_stages table enables custom stages beyond spec fixed enum columns",
      "spec_ref": "Data Model leads.stage CHECK NEW…LOST only; Kanban five fixed columns",
      "code_ref": "packages/db/src/schema/marketing.ts:46",
      "evidence": "pipeline_stages table + CRUD routes (leads.ts:71-144) allow per-tenant custom slugs. Spec Kanban diagram shows fixed NEW…WON columns only.",
      "repro": "POST /api/marketing/leads/stages adds custom column not in spec stage set."
    },
    {
      "id": "marketing-leads-pipeline-029",
      "severity": "P2",
      "type": "divergent",
      "summary": "Pipeline filter bar not URL-synced; no localStorage view toggle",
      "spec_ref": "Filter bar URL-synced; View toggle Kanban|List in localStorage",
      "code_ref": "apps/zync-app/src/routes/marketing/pipeline.tsx:463",
      "evidence": "grep URLSearchParams|localStorage|searchParams in pipeline.tsx → no matches. includeLost is React state only (line 463).",
      "repro": "Set filters on pipeline → URL unchanged; reload loses filter state."
    },
    {
      "id": "marketing-leads-pipeline-030",
      "severity": "P2",
      "type": "divergent",
      "summary": "New lead sheet missing assign-to field",
      "spec_ref": "Pipeline + New lead — assign to, initial stage",
      "code_ref": "apps/zync-app/src/routes/marketing/pipeline.tsx:546",
      "evidence": "handleNewLead POST body has name/email/phone/company/estimated_value/notes/stage/source only — no assigned_to. NewLeadSheet form fields (lines 317-348) omit assignee picker.",
      "repro": "Create lead from pipeline UI cannot set assigned_to."
    },
    {
      "id": "marketing-leads-pipeline-031",
      "severity": "P2",
      "type": "divergent",
      "summary": "marketing router lacks requireModuleEnabled('marketing') gate",
      "spec_ref": "Plan Task 6 — requireModuleEnabled('marketing') on all marketing routes",
      "code_ref": "apps/zync-api/src/routes/marketing/router.ts:34",
      "evidence": "marketingRouter.use('*', authMiddleware) only. grep requireModuleEnabled in apps/zync-api/src/routes/marketing/ → no matches (present on proposals-editor.ts:57).",
      "repro": "Tenant with marketing module disabled can still call /api/marketing/leads if user has permission."
    },
    {
      "id": "marketing-leads-pipeline-032",
      "severity": "P2",
      "type": "divergent",
      "summary": "addLeadActivity inserts without verifying lead_id belongs to tenant_id",
      "spec_ref": "Architecture tenant_id row isolation on all writes",
      "code_ref": "packages/db/src/queries/marketing.ts:657",
      "evidence": "INSERT into lead_activities uses tenantId+leadId params with no SELECT on leads confirming tenant ownership. Routes call getLead first, but repo has no enforcement.",
      "repro": "Direct repo call addLeadActivity(db, tenantB, leadIdFromTenantA, …) would insert mismatched tenant_id/lead_id if FK allows."
    },
    {
      "id": "marketing-leads-pipeline-033",
      "severity": "P2",
      "type": "divergent",
      "summary": "Kanban pipeline endpoint caps at 100 leads — large boards truncated",
      "spec_ref": "Pipeline Kanban shows all active leads per stage",
      "code_ref": "apps/zync-api/src/routes/marketing/leads.ts:161",
      "evidence": "GET /pipeline calls listLeads with limit: 100 hardcoded (line 161). No pagination for board load.",
      "repro": "Tenant with >100 active leads → pipeline API omits excess cards silently."
    },
    {
      "id": "marketing-leads-pipeline-034",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Global search lead result URL points to /crm/leads not marketing pipeline",
      "spec_ref": "Global Search result links to pipeline panel",
      "code_ref": "packages/db/src/search/entity-queries.ts:441",
      "evidence": "searchLeads maps url: `/crm/leads/${r.id}` while app route is /marketing/leads/:id (marketing.tsx:55).",
      "repro": "Search hit click navigates to non-existent /crm/leads/:id."
    },
    {
      "id": "marketing-leads-pipeline-035",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Pipeline card shows assignee UUID prefix not user avatar/name",
      "spec_ref": "Lead card — assigned user avatar",
      "code_ref": "apps/zync-app/src/routes/marketing/pipeline.tsx:130",
      "evidence": "Avatar fallback={lead.assignedTo.slice(0, 2).toUpperCase()} uses raw user id hex, not resolved display name.",
      "repro": "Assigned lead card shows two hex chars of UUID instead of user initials/name."
    },
    {
      "id": "marketing-leads-pipeline-036",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Overview leads-by-source uses badge list not pie chart",
      "spec_ref": "Overview — Leads by source pie chart",
      "code_ref": "apps/zync-app/src/routes/marketing/overview.tsx:292",
      "evidence": "SourceBreakdown component renders Badge counts (overview.tsx SourceBreakdown), not pie/donut chart.",
      "repro": "Overview page shows source badges instead of pie visualization."
    }
  ],
  "summary": {
    "P0": 4,
    "P1": 14,
    "P2": 15,
    "P3": 3,
    "total": 36
  }
}
