diff --git a/modules/A1-lead-enrich-pipeline/README.md b/modules/A1-lead-enrich-pipeline/README.md index 2ef460c..c2111d4 100644 --- a/modules/A1-lead-enrich-pipeline/README.md +++ b/modules/A1-lead-enrich-pipeline/README.md @@ -156,15 +156,47 @@ These are the nodes you'd modify when deploying for a client: 3. **Grading thresholds** — adjust in the Enrichment Mapper & Grader Code node 4. **Actions workflow** — replace `lead-actions-example` with client-specific routing +## Apollo Simulator (Test Mode) + +When `TEST_MODE=true`, the workflow routes through an **Apollo Simulator** Code node instead of the real API. The simulator generates Apollo-shaped responses with varying data quality based on the email prefix: + +| Email prefix | Simulated response | Expected grade | +|---|---|---| +| `full@` (or any other) | All person + org fields populated | Full Profile | +| `partial@` | Company name present, title and employee count null | Partial Profile | +| `limited@` | Person data but empty organisation object | Limited | +| `nomatch@` or `empty@` | `person: null`, zero credits consumed | Limited | +| `error@` | HTTP 429 rate limit, zero remaining | Limited (error) | + +The simulator output goes through the **same Enrichment Mapper & Grader** as real Apollo responses. This means test mode exercises the actual parsing logic, not a bypass. + ## Test Results (2026/03/28) +### Validation Tests + | Test | Result | Notes | |---|---|---| -| Invalid email (bad format) | PASS — 422, correct error message | | -| Missing email | PASS — 422, "Email is missing or empty" | | -| Test mode (mock enrichment) | PASS — 200, Full Profile, 13 fields, provider=mock | | -| Live Apollo (valid email) | PASS — 200, Limited grade, provider=apollo | Apollo free plan blocks people/match endpoint. Workflow handles gracefully. | -| Lead data in response | PASS — lead object present in all paths | Bug fixed: upstream node reference for Apollo path | +| Invalid email (bad format) | PASS — 422 | Correct error message returned | +| Missing email | PASS — 422 | "Email is missing or empty" | + +### Grading Path Tests (via Apollo Simulator) + +| Test | Result | Notes | +|---|---|---| +| Full Profile (`full@test.com`) | PASS — 200 | Grade: Full Profile, 13 fields, provider: apollo | +| Partial Profile (`partial@test.com`) | PASS — 200 | Grade: Partial Profile, 8 fields, title: null, employees: null | +| Limited - no org (`limited@test.com`) | PASS — 200 | Grade: Limited, 6 fields, company: null | +| No match (`nomatch@test.com`) | PASS — 200 | Grade: Limited, 0 fields, credits: 0, no error | +| Rate limit error (`error@test.com`) | PASS — 200 | Grade: Limited, error: true, rate_remaining: 0, credits_low: true | +| Lead data in response | PASS | Lead object present in all paths | + +### Throughput Tests + +| Concurrent | All 200s | Wall time | Throughput | Notes | +|---|---|---|---|---| +| 10 | Yes | 543ms | ~18 req/sec | Avg 345ms per request | +| 25 | Yes | 989ms | ~25 req/sec | Avg 575ms per request | +| 50 | Yes | 3.95s | ~12 req/sec | Tail latency 3.3s (n8n queueing). Acceptable for lead volumes. | ## Files diff --git a/modules/A1-lead-enrich-pipeline/workflows/lead-enrich-core.json b/modules/A1-lead-enrich-pipeline/workflows/lead-enrich-core.json index 7dfc1c1..a52d11a 100644 --- a/modules/A1-lead-enrich-pipeline/workflows/lead-enrich-core.json +++ b/modules/A1-lead-enrich-pipeline/workflows/lead-enrich-core.json @@ -2,6 +2,12 @@ "name": "Lead Enrich Core", "nodes": [ { + "parameters": { + "httpMethod": "POST", + "path": "lead-enrich", + "responseMode": "responseNode", + "options": {} + }, "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Webhook", "type": "n8n-nodes-base.webhook", @@ -10,26 +16,10 @@ 0, 0 ], - "webhookId": "lead-enrich", - "parameters": { - "path": "lead-enrich", - "httpMethod": "POST", - "responseMode": "responseNode", - "options": {} - } + "webhookId": "lead-enrich" }, { - "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", - "name": "Input Mapper", - "type": "n8n-nodes-base.set", - "typeVersion": 3.3, - "position": [ - 300, - 0 - ], "parameters": { - "mode": "manual", - "duplicateItem": false, "assignments": { "assignments": [ { @@ -77,36 +67,37 @@ ] }, "options": {} - } + }, + "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "name": "Input Mapper", + "type": "n8n-nodes-base.set", + "typeVersion": 3.3, + "position": [ + 304, + 0 + ] }, { + "parameters": { + "jsCode": "const items = $input.all();\nconst results = [];\n\nfor (const item of items) {\n const lead = item.json.lead;\n\n // Trim all string fields\n for (const key of Object.keys(lead)) {\n if (typeof lead[key] === 'string') {\n lead[key] = lead[key].trim();\n }\n }\n\n // Lowercase email\n lead.email = (lead.email || '').toLowerCase();\n\n // Validate email format\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n if (!lead.email || !emailRegex.test(lead.email)) {\n item.json._validation_error = true;\n item.json._error_reason = !lead.email\n ? 'Email is missing or empty'\n : `Invalid email format: ${lead.email}`;\n } else {\n item.json._validation_error = false;\n }\n\n // Ensure submitted_at exists\n if (!lead.submitted_at) {\n lead.submitted_at = new Date().toISOString();\n }\n\n results.push(item);\n}\n\nreturn results;" + }, "id": "d0e1f2a3-b4c5-6789-defa-890123456789", "name": "Clean & Validate", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 600, + 608, 0 - ], - "parameters": { - "mode": "runOnceForAllItems", - "jsCode": "const items = $input.all();\nconst results = [];\n\nfor (const item of items) {\n const lead = item.json.lead;\n\n // Trim all string fields\n for (const key of Object.keys(lead)) {\n if (typeof lead[key] === 'string') {\n lead[key] = lead[key].trim();\n }\n }\n\n // Lowercase email\n lead.email = (lead.email || '').toLowerCase();\n\n // Validate email format\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n if (!lead.email || !emailRegex.test(lead.email)) {\n item.json._validation_error = true;\n item.json._error_reason = !lead.email\n ? 'Email is missing or empty'\n : `Invalid email format: ${lead.email}`;\n } else {\n item.json._validation_error = false;\n }\n\n // Ensure submitted_at exists\n if (!lead.submitted_at) {\n lead.submitted_at = new Date().toISOString();\n }\n\n results.push(item);\n}\n\nreturn results;" - } + ] }, { - "id": "e1f2a3b4-c5d6-7890-efab-901234567890", - "name": "Validation Router", - "type": "n8n-nodes-base.if", - "typeVersion": 2, - "position": [ - 900, - 0 - ], "parameters": { "conditions": { "options": { "caseSensitive": true, - "leftValue": "" + "leftValue": "", + "typeValidation": "strict", + "version": 1 }, "conditions": [ { @@ -122,20 +113,18 @@ "combinator": "and" }, "options": {} - } + }, + "id": "e1f2a3b4-c5d6-7890-efab-901234567890", + "name": "Validation Router", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 912, + 0 + ] }, { - "id": "a3b4c5d6-e7f8-9012-abcd-123456789012", - "name": "Dead Letter", - "type": "n8n-nodes-base.set", - "typeVersion": 3.3, - "position": [ - 1200, - -200 - ], "parameters": { - "mode": "manual", - "duplicateItem": false, "assignments": { "assignments": [ { @@ -165,34 +154,34 @@ ] }, "options": {} - } + }, + "id": "a3b4c5d6-e7f8-9012-abcd-123456789012", + "name": "Dead Letter", + "type": "n8n-nodes-base.set", + "typeVersion": 3.3, + "position": [ + 1200, + -208 + ] }, { - "id": "f8a9b0c1-d2e3-4567-fabc-678901234567", - "name": "Dead Letter Response", - "type": "n8n-nodes-base.respondToWebhook", - "typeVersion": 1.1, - "position": [ - 1500, - -200 - ], "parameters": { "respondWith": "json", "responseBody": "={{ { error: true, error_reason: $json.error_reason, lead: $json.lead, timestamp: $json.timestamp } }}", "options": { "responseCode": 422 } - } + }, + "id": "f8a9b0c1-d2e3-4567-fabc-678901234567", + "name": "Dead Letter Response", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 1504, + -208 + ] }, { - "id": "a9b0c1d2-e3f4-5678-abcd-789012345678", - "name": "Test Mode Check", - "type": "n8n-nodes-base.if", - "typeVersion": 2, - "position": [ - 1200, - 200 - ], "parameters": { "conditions": { "options": { @@ -213,174 +202,31 @@ "combinator": "and" }, "options": {} - } + }, + "id": "a9b0c1d2-e3f4-5678-abcd-789012345678", + "name": "Test Mode Check", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1200, + 208 + ] }, { - "id": "c1d2e3f4-a5b6-7890-cdef-901234567890", - "name": "Mock Enrichment", - "type": "n8n-nodes-base.set", - "typeVersion": 3.3, + "id": "849c61c7-ea72-4816-b0d7-aca0ace3ea9a", + "name": "Apollo Simulator", + "type": "n8n-nodes-base.code", + "typeVersion": 2, "position": [ - 1500, - 100 + 1504, + 112 ], "parameters": { - "mode": "manual", - "duplicateItem": false, - "assignments": { - "assignments": [ - { - "id": "d2e3f4a5-b6c7-8901-defa-012345678901", - "name": "lead", - "value": "={{ $json.lead }}", - "type": "object" - }, - { - "id": "e3f4a5b6-c7d8-9012-efab-123456789012", - "name": "enrichment.provider", - "value": "mock", - "type": "string" - }, - { - "id": "f4a5b6c7-d8e9-0123-fabc-234567890123", - "name": "enrichment.person.title", - "value": "Head of Marketing", - "type": "string" - }, - { - "id": "a5b6c7d8-e9f0-1234-abcd-345678901234", - "name": "enrichment.person.linkedin_url", - "value": "https://linkedin.com/in/test-person", - "type": "string" - }, - { - "id": "b6c7d8e9-f0a1-2345-bcde-456789012345", - "name": "enrichment.person.city", - "value": "London", - "type": "string" - }, - { - "id": "c7d8e9f0-a1b2-3456-cdef-567890123456", - "name": "enrichment.person.state", - "value": "England", - "type": "string" - }, - { - "id": "d8e9f0a1-b2c3-4567-defa-678901234567", - "name": "enrichment.person.country", - "value": "United Kingdom", - "type": "string" - }, - { - "id": "e9f0a1b2-c3d4-5678-efab-789012345678", - "name": "enrichment.person.seniority", - "value": "Director", - "type": "string" - }, - { - "id": "f0a1b2c3-d4e5-6789-fabc-890123456789", - "name": "enrichment.organisation.name", - "value": "Acme Corp", - "type": "string" - }, - { - "id": "a1b2c3d4-e5f6-7890-abcd-901234567890", - "name": "enrichment.organisation.domain", - "value": "acmecorp.com", - "type": "string" - }, - { - "id": "b2c3d4e5-f6a7-8901-bcde-012345678901", - "name": "enrichment.organisation.industry", - "value": "Software", - "type": "string" - }, - { - "id": "c3d4e5f6-a7b8-9012-cdef-123456789013", - "name": "enrichment.organisation.estimated_employees", - "value": 250, - "type": "number" - }, - { - "id": "d4e5f6a7-b8c9-0123-defa-234567890124", - "name": "enrichment.organisation.annual_revenue", - "value": 5000000, - "type": "number" - }, - { - "id": "e5f6a7b8-c9d0-1234-efab-345678901235", - "name": "enrichment.organisation.linkedin_url", - "value": "https://linkedin.com/company/acmecorp", - "type": "string" - }, - { - "id": "f6a7b8c9-d0e1-2345-fabc-456789012346", - "name": "enrichment.organisation.website_url", - "value": "https://acmecorp.com", - "type": "string" - }, - { - "id": "a7b8c9d0-e1f2-3456-abcd-567890123457", - "name": "enrichment.enrichment_error", - "value": false, - "type": "boolean" - }, - { - "id": "b8c9d0e1-f2a3-4567-bcde-678901234568", - "name": "enrichment.credits_consumed", - "value": 0, - "type": "number" - }, - { - "id": "c9d0e1f2-a3b4-5678-cdef-789012345679", - "name": "enrichment.rate_limit_remaining", - "value": null, - "type": "null" - }, - { - "id": "d0e1f2a3-b4c5-6789-defa-890123456780", - "name": "enrichment.credits_low", - "value": false, - "type": "boolean" - }, - { - "id": "e1f2a3b4-c5d6-7890-efab-901234567891", - "name": "grading.profile_grade", - "value": "Full Profile", - "type": "string" - }, - { - "id": "f2a3b4c5-d6e7-8901-fabc-012345678902", - "name": "grading.fields_populated", - "value": 13, - "type": "number" - }, - { - "id": "a3b4c5d6-e7f8-9012-abcd-123456789013", - "name": "grading.graded_at", - "value": "={{ $now.toISO() }}", - "type": "string" - } - ] - }, - "options": { - "includeOtherFields": true - } + "mode": "runOnceForAllItems", + "jsCode": "const items = $input.all();\nconst results = [];\n\nfor (const item of items) {\n const lead = item.json.lead;\n const email = (lead.email || '').toLowerCase();\n\n // Determine response quality based on email pattern\n // full@ -> Full Profile (all fields populated)\n // partial@ -> Partial Profile (company but missing title/employees)\n // empty@ -> No match (person: null)\n // error@ -> API error (429 rate limit)\n // limited@ -> Limited (person found but no org data)\n // anything else -> random quality based on email hash\n\n let statusCode = 200;\n let headers = { 'x-minute-requests-left': '87', 'x-hourly-requests-left': '950' };\n let body = {};\n\n if (email.startsWith('error@')) {\n // Simulate 429 rate limit\n statusCode = 429;\n body = { error: 'Rate limit exceeded' };\n headers['x-minute-requests-left'] = '0';\n\n } else if (email.startsWith('empty@') || email.startsWith('nomatch@')) {\n // No match found\n body = { person: null, credits_consumed: 0 };\n\n } else if (email.startsWith('partial@')) {\n // Partial: company name but no title or employee count\n body = {\n person: {\n first_name: lead.first_name || 'Jane',\n last_name: lead.last_name || 'Doe',\n title: null,\n linkedin_url: 'https://linkedin.com/in/janedoe',\n city: 'Manchester',\n state: 'England',\n country: 'United Kingdom',\n seniority: null,\n organization: {\n name: lead.company_name || 'PartialCo Ltd',\n primary_domain: 'partialco.com',\n industry: 'Manufacturing',\n estimated_num_employees: null,\n annual_revenue: null,\n linkedin_url: null,\n website_url: 'https://partialco.com'\n }\n },\n credits_consumed: 1\n };\n\n } else if (email.startsWith('limited@')) {\n // Limited: person found but no org at all\n body = {\n person: {\n first_name: lead.first_name || 'Sam',\n last_name: lead.last_name || 'Solo',\n title: 'Freelance Consultant',\n linkedin_url: 'https://linkedin.com/in/samsolo',\n city: 'Bristol',\n state: 'England',\n country: 'United Kingdom',\n seniority: 'Entry',\n organization: {}\n },\n credits_consumed: 1\n };\n\n } else {\n // Full Profile (default for full@ and anything else in test mode)\n body = {\n person: {\n first_name: lead.first_name || 'Sarah',\n last_name: lead.last_name || 'Chen',\n title: 'Head of Operations',\n linkedin_url: 'https://linkedin.com/in/sarahchen',\n city: 'London',\n state: 'England',\n country: 'United Kingdom',\n seniority: 'Director',\n organization: {\n name: lead.company_name || 'Acme Industries',\n primary_domain: 'acmeindustries.co.uk',\n industry: 'Technology',\n estimated_num_employees: 340,\n annual_revenue: 12000000,\n linkedin_url: 'https://linkedin.com/company/acme-industries',\n website_url: 'https://acmeindustries.co.uk'\n }\n },\n credits_consumed: 1\n };\n }\n\n // Output in the same shape as HTTP Request node with fullResponse: true\n results.push({\n json: {\n statusCode,\n headers,\n body,\n // Preserve lead data for downstream reference\n lead\n }\n });\n}\n\nreturn results;" } }, { - "id": "b4c5d6e7-f8a9-0123-bcde-234567890124", - "name": "Apollo HTTP Request", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [ - 1500, - 350 - ], - "retryOnFail": true, - "maxTries": 3, - "waitBetweenTries": 5000, - "continueOnFail": true, "parameters": { "method": "POST", "url": "https://api.apollo.io/api/v1/people/match", @@ -408,38 +254,64 @@ }, "timeout": 30000 } - } + }, + "id": "b4c5d6e7-f8a9-0123-bcde-234567890124", + "name": "Apollo HTTP Request", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1504, + 352 + ], + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 5000, + "continueOnFail": true }, { + "parameters": { + "jsCode": "const items = $input.all();\nconst results = [];\n\nfor (const item of items) {\n const response = item.json;\n // Get lead: from response (simulator includes it), or upstream node, or empty\n let lead = response.lead;\n if (!lead) {\n try {\n lead = $('Test Mode Check').first().json.lead;\n } catch(e) {\n lead = {};\n }\n }\n\n // Initialise enrichment structure\n const enrichment = {\n provider: 'apollo',\n person: {\n title: null,\n linkedin_url: null,\n city: null,\n state: null,\n country: null,\n seniority: null\n },\n organisation: {\n name: null,\n domain: null,\n industry: null,\n estimated_employees: null,\n annual_revenue: null,\n linkedin_url: null,\n website_url: null\n },\n enrichment_error: false,\n credits_consumed: 0,\n rate_limit_remaining: null,\n credits_low: false\n };\n\n // Check for HTTP errors via the response metadata\n const statusCode = response.statusCode || response.$response?.statusCode || 200;\n\n // Extract rate limit headers if available\n const headers = response.headers || response.$response?.headers || {};\n const rateRemaining = headers['x-minute-requests-left'];\n if (rateRemaining !== undefined) {\n enrichment.rate_limit_remaining = parseInt(rateRemaining, 10);\n enrichment.credits_low = enrichment.rate_limit_remaining < 50;\n }\n\n // Handle response based on status\n if (statusCode === 429) {\n enrichment.enrichment_error = true;\n } else if (statusCode >= 400) {\n enrichment.enrichment_error = true;\n } else {\n // Look for person data in the response body\n const body = response.body || response;\n const person = body.person || null;\n\n if (person && person !== null) {\n const org = person.organization || {};\n\n enrichment.person.title = person.title || null;\n enrichment.person.linkedin_url = person.linkedin_url || null;\n enrichment.person.city = person.city || null;\n enrichment.person.state = person.state || null;\n enrichment.person.country = person.country || null;\n enrichment.person.seniority = person.seniority || null;\n\n enrichment.organisation.name = org.name || null;\n enrichment.organisation.domain = org.primary_domain || null;\n enrichment.organisation.industry = org.industry || null;\n enrichment.organisation.estimated_employees = org.estimated_num_employees || null;\n enrichment.organisation.annual_revenue = org.annual_revenue || null;\n enrichment.organisation.linkedin_url = org.linkedin_url || null;\n enrichment.organisation.website_url = org.website_url || null;\n\n enrichment.credits_consumed = body.credits_consumed || 1;\n }\n // else: person is null \u00e2\u20ac\u201d no match, all fields stay null, no error, no credits\n }\n\n // Grade the profile\n const hasCompany = enrichment.organisation.name !== null;\n const hasTitle = enrichment.person.title !== null;\n const hasEmployees = enrichment.organisation.estimated_employees !== null;\n\n let profileGrade;\n if (enrichment.enrichment_error || !hasCompany) {\n profileGrade = 'Limited';\n } else if (hasCompany && hasTitle && hasEmployees) {\n profileGrade = 'Full Profile';\n } else {\n profileGrade = 'Partial Profile';\n }\n\n // Count populated fields\n const personFields = Object.values(enrichment.person).filter(v => v !== null).length;\n const orgFields = Object.values(enrichment.organisation).filter(v => v !== null).length;\n\n const grading = {\n profile_grade: profileGrade,\n fields_populated: personFields + orgFields,\n graded_at: new Date().toISOString()\n };\n\n results.push({\n json: {\n lead,\n enrichment,\n grading\n }\n });\n}\n\nreturn results;" + }, "id": "c5d6e7f8-a9b0-1234-cdef-345678901235", "name": "Enrichment Mapper & Grader", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ - 1800, - 350 - ], - "parameters": { - "mode": "runOnceForAllItems", - "jsCode": "const items = $input.all();\nconst results = [];\n\nfor (const item of items) {\n const response = item.json;\n // Lead data lost when HTTP Request uses fullResponse — reference upstream\n let lead;\n try {\n lead = $('Test Mode Check').first().json.lead;\n } catch(e) {\n lead = response.lead || {};\n }\n\n // Initialise enrichment structure\n const enrichment = {\n provider: 'apollo',\n person: {\n title: null,\n linkedin_url: null,\n city: null,\n state: null,\n country: null,\n seniority: null\n },\n organisation: {\n name: null,\n domain: null,\n industry: null,\n estimated_employees: null,\n annual_revenue: null,\n linkedin_url: null,\n website_url: null\n },\n enrichment_error: false,\n credits_consumed: 0,\n rate_limit_remaining: null,\n credits_low: false\n };\n\n // Check for HTTP errors via the response metadata\n const statusCode = response.statusCode || response.$response?.statusCode || 200;\n\n // Extract rate limit headers if available\n const headers = response.headers || response.$response?.headers || {};\n const rateRemaining = headers['x-minute-requests-left'];\n if (rateRemaining !== undefined) {\n enrichment.rate_limit_remaining = parseInt(rateRemaining, 10);\n enrichment.credits_low = enrichment.rate_limit_remaining < 50;\n }\n\n // Handle response based on status\n if (statusCode === 429) {\n enrichment.enrichment_error = true;\n } else if (statusCode >= 400) {\n enrichment.enrichment_error = true;\n } else {\n // Look for person data in the response body\n const body = response.body || response;\n const person = body.person || null;\n\n if (person && person !== null) {\n const org = person.organization || {};\n\n enrichment.person.title = person.title || null;\n enrichment.person.linkedin_url = person.linkedin_url || null;\n enrichment.person.city = person.city || null;\n enrichment.person.state = person.state || null;\n enrichment.person.country = person.country || null;\n enrichment.person.seniority = person.seniority || null;\n\n enrichment.organisation.name = org.name || null;\n enrichment.organisation.domain = org.primary_domain || null;\n enrichment.organisation.industry = org.industry || null;\n enrichment.organisation.estimated_employees = org.estimated_num_employees || null;\n enrichment.organisation.annual_revenue = org.annual_revenue || null;\n enrichment.organisation.linkedin_url = org.linkedin_url || null;\n enrichment.organisation.website_url = org.website_url || null;\n\n enrichment.credits_consumed = body.credits_consumed || 1;\n }\n // else: person is null \u2014 no match, all fields stay null, no error, no credits\n }\n\n // Grade the profile\n const hasCompany = enrichment.organisation.name !== null;\n const hasTitle = enrichment.person.title !== null;\n const hasEmployees = enrichment.organisation.estimated_employees !== null;\n\n let profileGrade;\n if (enrichment.enrichment_error || !hasCompany) {\n profileGrade = 'Limited';\n } else if (hasCompany && hasTitle && hasEmployees) {\n profileGrade = 'Full Profile';\n } else {\n profileGrade = 'Partial Profile';\n }\n\n // Count populated fields\n const personFields = Object.values(enrichment.person).filter(v => v !== null).length;\n const orgFields = Object.values(enrichment.organisation).filter(v => v !== null).length;\n\n const grading = {\n profile_grade: profileGrade,\n fields_populated: personFields + orgFields,\n graded_at: new Date().toISOString()\n };\n\n results.push({\n json: {\n lead,\n enrichment,\n grading\n }\n });\n}\n\nreturn results;" - } + 1808, + 352 + ] }, { - "id": "d6e7f8a9-b0c1-2345-defa-456789012346", - "name": "Success Response", - "type": "n8n-nodes-base.respondToWebhook", - "typeVersion": 1.1, - "position": [ - 2100, - 200 - ], "parameters": { "respondWith": "json", "responseBody": "={{ $json }}", "options": { "responseCode": 200 } - } + }, + "id": "d6e7f8a9-b0c1-2345-defa-456789012346", + "name": "Success Response", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 2112, + 208 + ] + }, + { + "parameters": { + "workflowId": "xrvKeRGJ297kWSno", + "options": {} + }, + "id": "e12f94e6-c02d-4a40-b969-05d4a1251dc9", + "name": "Call Actions Workflow", + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.2, + "position": [ + 2400, + 200 + ], + "continueOnFail": true } ], "connections": { @@ -509,7 +381,7 @@ "main": [ [ { - "node": "Mock Enrichment", + "node": "Apollo Simulator", "type": "main", "index": 0 } @@ -523,17 +395,6 @@ ] ] }, - "Mock Enrichment": { - "main": [ - [ - { - "node": "Success Response", - "type": "main", - "index": 0 - } - ] - ] - }, "Apollo HTTP Request": { "main": [ [ @@ -555,10 +416,33 @@ } ] ] + }, + "Success Response": { + "main": [ + [ + { + "node": "Call Actions Workflow", + "type": "main", + "index": 0 + } + ] + ] + }, + "Apollo Simulator": { + "main": [ + [ + { + "node": "Enrichment Mapper & Grader", + "type": "main", + "index": 0 + } + ] + ] } }, "settings": { - "executionOrder": "v1" - }, - "pinData": {} + "executionOrder": "v1", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false + } } \ No newline at end of file