{
  "name": "03 — Contact Discovery + Email Verify",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "*/15 * * * *"
            }
          ]
        }
      },
      "id": "wf03-0001",
      "name": "Every 15 Min",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.1,
      "position": [
        240,
        300
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT l.* FROM leads.leads l\nWHERE l.state = 'qualified'\n  AND l.research_artifact IS NOT NULL\n  AND NOT EXISTS (\n    SELECT 1 FROM leads.lead_contacts lc\n    WHERE lc.lead_id = l.id AND lc.is_primary = true\n  )\nORDER BY l.updated_at ASC LIMIT 5",
        "additionalFields": {}
      },
      "id": "wf03-0002",
      "name": "Fetch Leads Needing Contacts",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        460,
        300
      ]
    },
    {
      "parameters": {
        "batchSize": 1,
        "options": {}
      },
      "id": "wf03-0003",
      "name": "Split Leads",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        680,
        300
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT * FROM leads.lead_contacts WHERE lead_id = $1 AND role_tier = 'c_level'",
        "additionalFields": {
          "queryParams": "={{ JSON.stringify([$json.id]) }}"
        }
      },
      "id": "wf03-0004",
      "name": "Load Existing C-Level",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        900,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const contacts = $input.all().map(i => i.json);\nconst lead = $('Split Leads').item.json;\nreturn [{ json: { lead, existing_clevel: contacts, has_enough: contacts.length >= 2 } }];"
      },
      "id": "wf03-0005",
      "name": "Count C-Level Contacts",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1120,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "conditions": [
            {
              "id": "has-contacts",
              "leftValue": "={{ $json.has_enough }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "equal"
              }
            }
          ],
          "combinator": "and"
        }
      },
      "id": "wf03-0006",
      "name": "Has 2+ C-Level?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1340,
        300
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "http://playwright-worker:8000/scrape-google-search",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "q",
              "value": "=site:linkedin.com/in \"{{ $json.lead.company_name }}\" (CEO OR Founder OR Owner OR \"Managing Director\")"
            },
            {
              "name": "num",
              "value": "5"
            }
          ]
        },
        "options": {
          "timeout": 30000
        }
      },
      "id": "wf03-0007",
      "name": "Google: LinkedIn C-Level Search",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1560,
        420
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://playwright-worker:8000/scrape-registry",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ company_name: $('Count C-Level Contacts').item.json.lead.company_name, country: $('Count C-Level Contacts').item.json.lead.country }) }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "wf03-0008",
      "name": "Registry Lookup",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1560,
        600
      ]
    },
    {
      "parameters": {
        "jsCode": "const linkedinResults = $('Google: LinkedIn C-Level Search').item.json.results || [];\nconst registryOfficers = $('Registry Lookup').item.json.officers || [];\nconst existing = $('Count C-Level Contacts').item.json.existing_clevel || [];\nconst lead = $('Count C-Level Contacts').item.json.lead;\n\nconst C_LEVEL_TITLES = ['ceo','founder','co-founder','owner','managing director','principal','managing partner','general manager','cto','coo','president','director general'];\n\nfunction classify_tier(title) {\n  const t = (title || '').toLowerCase();\n  return C_LEVEL_TITLES.some(k => t.includes(k)) ? 'c_level' : 'director';\n}\n\n// Parse LinkedIn snippets for names/titles\nconst fromLinkedIn = linkedinResults.map(r => {\n  const titleMatch = r.title?.match(/^([^-|]+)/);\n  const name = titleMatch ? titleMatch[1].trim() : '';\n  const roleMatch = r.snippet?.match(/(CEO|Founder|Owner|Director|Partner|Manager)/i);\n  const role = roleMatch ? roleMatch[0] : 'Director';\n  return { full_name: name, role_title: role, found_via: 'linkedin_snippet', linkedin_url: r.url };\n}).filter(c => c.full_name.length > 3);\n\n// Combine all contacts, dedupe by lowercase name\nconst all = [\n  ...existing.map(c => ({ ...c, found_via: c.found_via || 'website' })),\n  ...fromLinkedIn,\n  ...registryOfficers.map(o => ({ ...o, found_via: 'registry' })),\n];\n\nconst seen = new Set();\nconst deduped = all.filter(c => {\n  const key = (c.full_name || '').toLowerCase().trim();\n  if (!key || seen.has(key)) return false;\n  seen.add(key);\n  return true;\n});\n\n// Classify tiers\nconst classified = deduped.map(c => ({\n  ...c,\n  role_tier: classify_tier(c.role_title),\n  lead_id: lead.id,\n  domain: lead.domain,\n}));\n\nreturn classified.map(c => ({ json: c }));"
      },
      "id": "wf03-0009",
      "name": "Merge + Dedupe Contacts",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1780,
        500
      ]
    },
    {
      "parameters": {
        "batchSize": 1,
        "options": {}
      },
      "id": "wf03-0010",
      "name": "Split Contacts",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        2000,
        500
      ]
    },
    {
      "parameters": {
        "jsCode": "const contact = $json;\nif (contact.email) {\n  return [{ json: { ...contact, permutations: [contact.email], skip_verify: false } }];\n}\n\nconst { unidecode } = await import('unidecode');\nconst raw = contact.full_name || '';\nconst parts = raw.trim().split(/\\s+/);\nconst first = unidecode(parts[0] || 'first').toLowerCase().replace(/[^a-z]/g,'');\nconst last  = unidecode(parts.slice(1).join('') || 'last').toLowerCase().replace(/[^a-z]/g,'');\nconst f = first[0] || 'f';\nconst l = last[0] || 'l';\nconst domain = contact.domain;\n\nconst perms = [\n  `${first}.${last}@${domain}`,\n  `${first}@${domain}`,\n  `${f}.${last}@${domain}`,\n  `${first}${last}@${domain}`,\n  `${f}${last}@${domain}`,\n  `${first}_${last}@${domain}`,\n];\n\nreturn [{ json: { ...contact, permutations: perms, skip_verify: false } }];"
      },
      "id": "wf03-0011",
      "name": "Generate Email Permutations",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2220,
        500
      ]
    },
    {
      "parameters": {
        "jsCode": "// Split permutations into individual items for Reacher verification\nconst data = $json;\nreturn (data.permutations || []).map(email => ({ json: { ...data, email_to_verify: email } }));"
      },
      "id": "wf03-0012",
      "name": "Split Permutations",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2440,
        500
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://reacher:8080/v0/check_email",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ to_email: $json.email_to_verify }) }}",
        "options": {
          "timeout": 15000
        }
      },
      "id": "wf03-0013",
      "name": "Verify Email via Reacher",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2660,
        500
      ]
    },
    {
      "parameters": {
        "jsCode": "// Score reachability: valid=4, catch_all=3, risky=2, invalid=0\nconst result = $json;\nconst prev = $('Split Permutations').item.json;\n\nconst is_reachable = result.is_reachable || 'unknown';\nconst scores = { 'safe': 4, 'valid': 4, 'risky': 2, 'unknown': 1, 'invalid': 0 };\nconst score = scores[is_reachable] ?? 0;\n\nlet email_status = 'risky';\nif (is_reachable === 'safe' || is_reachable === 'valid') email_status = 'verified_valid';\nelse if (is_reachable === 'invalid') email_status = 'invalid';\nelse if (result.misc?.is_catch_all) email_status = 'catch_all';\n\nreturn [{ json: {\n  ...prev,\n  email: prev.email_to_verify,\n  email_status,\n  verify_score: score,\n  is_reachable,\n} }];"
      },
      "id": "wf03-0014",
      "name": "Score Email Result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2880,
        500
      ]
    },
    {
      "parameters": {
        "jsCode": "// Collect all verified permutations per contact and pick the best one\nconst all = $input.all().map(i => i.json);\n\n// Group by lead_id + full_name\nconst byContact = {};\nfor (const item of all) {\n  const key = `${item.lead_id}::${item.full_name}`;\n  if (!byContact[key]) byContact[key] = [];\n  if (item.email_status !== 'invalid') {\n    byContact[key].push(item);\n  }\n}\n\nconst results = [];\nfor (const [key, items] of Object.entries(byContact)) {\n  items.sort((a, b) => (b.verify_score || 0) - (a.verify_score || 0));\n  const best = items[0];\n  if (best) results.push({ json: best });\n}\n\nreturn results.length ? results : [{ json: { _no_valid_email: true } }];"
      },
      "id": "wf03-0015",
      "name": "Pick Best Email",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3100,
        500
      ]
    },
    {
      "parameters": {
        "jsCode": "// Assign is_primary to highest-authority contact\nconst all = $input.all().map(i => i.json).filter(i => !i._no_valid_email);\n\nconst tier_order = { c_level: 0, director: 1, manager: 2, other: 3 };\nconst score_order = { verified_valid: 0, catch_all: 1, risky: 2 };\n\nall.sort((a, b) => {\n  const td = (tier_order[a.role_tier] ?? 3) - (tier_order[b.role_tier] ?? 3);\n  if (td !== 0) return td;\n  return (score_order[a.email_status] ?? 3) - (score_order[b.email_status] ?? 3);\n});\n\nreturn all.map((c, idx) => ({ json: { ...c, is_primary: idx === 0 } }));"
      },
      "id": "wf03-0016",
      "name": "Assign Primary Contact",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3320,
        500
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO leads.lead_contacts (lead_id, full_name, role_title, role_tier, email, email_status, email_source, linkedin_url, found_via, is_primary)\nVALUES ($1, $2, $3, $4, $5, $6, 'smtp_verify', $7, $8, $9)\nON CONFLICT DO NOTHING",
        "additionalFields": {
          "queryParams": "={{ JSON.stringify([\n  $json.lead_id,\n  $json.full_name,\n  $json.role_title,\n  $json.role_tier,\n  $json.email,\n  $json.email_status,\n  $json.linkedin_url || null,\n  $json.found_via || 'unknown',\n  $json.is_primary\n]) }}"
        }
      },
      "id": "wf03-0017",
      "name": "Upsert Contact",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        3540,
        500
      ]
    },
    {
      "parameters": {},
      "id": "wf03-0018",
      "name": "Skip (enough contacts)",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1560,
        300
      ]
    }
  ],
  "connections": {
    "Every 15 Min": {
      "main": [
        [
          {
            "node": "Fetch Leads Needing Contacts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Leads Needing Contacts": {
      "main": [
        [
          {
            "node": "Split Leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Leads": {
      "main": [
        [],
        [
          {
            "node": "Load Existing C-Level",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Existing C-Level": {
      "main": [
        [
          {
            "node": "Count C-Level Contacts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Count C-Level Contacts": {
      "main": [
        [
          {
            "node": "Has 2+ C-Level?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has 2+ C-Level?": {
      "main": [
        [
          {
            "node": "Skip (enough contacts)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Google: LinkedIn C-Level Search",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google: LinkedIn C-Level Search": {
      "main": [
        [
          {
            "node": "Merge + Dedupe Contacts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Registry Lookup": {
      "main": [
        [
          {
            "node": "Merge + Dedupe Contacts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge + Dedupe Contacts": {
      "main": [
        [
          {
            "node": "Split Contacts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Contacts": {
      "main": [
        [],
        [
          {
            "node": "Generate Email Permutations",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Email Permutations": {
      "main": [
        [
          {
            "node": "Split Permutations",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Permutations": {
      "main": [
        [
          {
            "node": "Verify Email via Reacher",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify Email via Reacher": {
      "main": [
        [
          {
            "node": "Score Email Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Score Email Result": {
      "main": [
        [
          {
            "node": "Pick Best Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick Best Email": {
      "main": [
        [
          {
            "node": "Assign Primary Contact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assign Primary Contact": {
      "main": [
        [
          {
            "node": "Upsert Contact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert Contact": {
      "main": [
        [
          {
            "node": "Split Contacts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Skip (enough contacts)": {
      "main": [
        [
          {
            "node": "Split Leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}
