{
  "name": "Logixb2b — Detectar duplicados CRM",
  "meta": {
    "templateCredsSetupCompleted": false,
    "description": "Schedule diario → GET contactos CRM → heurística duplicados por email → Slack lista (sin borrar). Revisión humana."
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Europe/Madrid"
  },
  "active": false,
  "pinData": {},
  "tags": [
    {
      "name": "Logixb2b"
    },
    {
      "name": "recursos"
    },
    {
      "name": "crm"
    },
    {
      "name": "dedupe"
    }
  ],
  "nodes": [
    {
      "id": "sticky-instrucciones-logix",
      "name": "INSTRUCCIONES (conectar + VPS)",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -480,
        40
      ],
      "parameters": {
        "content": "## INSTRUCCIONES — Detectar duplicados CRM\n\n**Recurso:** `/recursos/dedupe-contactos-crm`\n**Guía:** /evitar-duplicados-leads-automatizar-crm\n**Zona:** Europe/Madrid · `active: false` al importar (tú lo activas).\n\n### 1) Importar\n1. n8n → ⋯ → **Import from File** → este JSON.\n2. El flujo llega **inactive**. No pegues secretos en el lienzo ni en el archivo.\n\n### 2) Credenciales y configuración por nodo\n- **Diario 02:00** (`schedule`): sin credencial. Revisa expresión cron / intervalo y zona **Europe/Madrid** (settings del workflow). Solo dispara si n8n está **Active** 24/7.\n- **Config dedupe** (`Set` Config): **no lleva credencial**. Aquí van URLs/placeholders (`REEMPLAZA_*`, `example.com`, `TU-CRM`). Sustituye todos antes de Active.\n- **GET contactos CRM** (`HTTP Request` CRM): Credentials → **Header Auth** / OAuth del CRM. Sustituye `crm*Url` en el nodo **Config**. Prueba con un Lead de sandbox.\n- **Normalizar + agrupar duplicados** (`Code`): sin credencial. Revisa límites/umbrales en el JS; no hardcodees secretos.\n- **¿GET CRM OK?** (`IF`): sin credencial. Comprueba la condición (email, umbral, over_limit, etc.) con datos reales de prueba.\n- **¿Hay posibles duplicados?** (`IF`): sin credencial. Comprueba la condición (email, umbral, over_limit, etc.) con datos reales de prueba.\n- **HTTP search heurística (opcional)** (`HTTP Request` CRM): Credentials → **Header Auth** / OAuth del CRM. Sustituye `crm*Url` en el nodo **Config**. Prueba con un Lead de sandbox.\n- **Slack — lista posibles duplicados** (`HTTP Request` → Slack/Teams): crea Incoming Webhook en Slack (o Adaptive Card URL en Teams). Pega la URL en **Config** o en este nodo. Auth: none (la URL ya es secreta).\n- **Slack — fallo GET CRM** (`HTTP Request` → Slack/Teams): crea Incoming Webhook en Slack (o Adaptive Card URL en Teams). Pega la URL en **Config** o en este nodo. Auth: none (la URL ya es secreta).\n\n### 3) Orden recomendado de puesta en marcha\n1. Rellena el nodo **Config** (URLs reales, umbrales, webhooks de Slack).\n2. Crea Credentials en n8n (candado de cada nodo HTTP / Sheets / OpenAI) y **enlázalas**.\n3. Ejecuta **una vez** (Manual / Test URL del Webhook / \"Execute workflow\").\n4. Verifica CRM + Slack/Sheets (y que la IA no escriba en producción sin HITL).\n5. Solo entonces: interruptor **Active**.\n\n### 4) Producción 24/7 en VPS (UE)\nImportar en localhost no basta: webhooks y cron necesitan n8n encendido.\n\n1. VPS en UE (Hetzner/OVH/IONOS…) + Docker Compose + **Postgres** + HTTPS (Caddy/Nginx).\n2. Variables mínimas: `GENERIC_TIMEZONE=Europe/Madrid`, `WEBHOOK_URL=https://n8n.tudominio.com/`, `N8N_ENCRYPTION_KEY`, `N8N_HOST` / `N8N_PROTOCOL=https`.\n3. Firewall: solo 80/443 (+ SSH). Backups del volumen Postgres + export de workflows.\n4. Importa este JSON en la instancia de producción, vuelve a crear Credentials (no se copian solas), actualiza URLs del Config al dominio real.\n5. Pon **Active**. Prueba el Webhook de producción o espera el próximo cron.\n6. Alternativa: **n8n Cloud** (mismo JSON; Credentials en la UI Cloud).\n\n### 5) RGPD + EU AI Act (resumen)\nSi hay nodos IA sois **deployers** (Reglamento UE 2024/1689). Art. 4 alfabetización. **HITL** antes de outreach, scores masivos o pausas de cuenta. Minimiza PII en Slack/logs. Informativo — no es asesoramiento legal. /uso-responsable-de-ia-y-transparencia\n",
        "height": 1500,
        "width": 580,
        "color": 4
      }
    },
    {
      "id": "dup-schedule",
      "name": "Diario 02:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        560
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 2 * * *"
            }
          ]
        }
      }
    },
    {
      "id": "dup-config",
      "name": "Config dedupe",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        300,
        560
      ],
      "parameters": {
        "mode": "manual",
        "duplicateItem": false,
        "assignments": {
          "assignments": [
            {
              "id": "dup-c1",
              "name": "crmContactsUrl",
              "value": "https://TU-CRM.ejemplo/api/contacts?limit=500",
              "type": "string"
            },
            {
              "id": "dup-c2",
              "name": "crmSearchUrl",
              "value": "https://TU-CRM.ejemplo/api/contacts/search?email=",
              "type": "string"
            },
            {
              "id": "dup-c3",
              "name": "crmApiKey",
              "value": "sustituye-con-tu-clave-del-crm",
              "type": "string"
            },
            {
              "id": "dup-c4",
              "name": "slackWebhookUrl",
              "value": "https://example.com/aviso-equipo-slack",
              "type": "string"
            },
            {
              "id": "dup-c5",
              "name": "checkedAt",
              "value": "={{ $now.setZone('Europe/Madrid').toFormat('yyyy-MM-dd HH:mm') }}",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "dup-get",
      "name": "GET contactos CRM",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        600,
        560
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ $json.crmContactsUrl }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $json.crmApiKey }}"
            }
          ]
        },
        "options": {
          "timeout": 40000,
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      }
    },
    {
      "id": "dup-code",
      "name": "Normalizar + agrupar duplicados",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        900,
        560
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const cfg = $('Config dedupe').first().json;\nconst resp = $input.first().json;\nconst statusCode = Number(resp.statusCode || 0);\nconst body = resp.body;\nconst list = Array.isArray(body?.contacts)\n  ? body.contacts\n  : Array.isArray(body?.data)\n    ? body.data\n    : Array.isArray(body)\n      ? body\n      : [];\n\nconst map = {};\nfor (const row of list) {\n  const email = String(row.email || row.Email || '').trim().toLowerCase();\n  if (!email || !email.includes('@')) continue;\n  if (!map[email]) map[email] = [];\n  map[email].push({\n    id: row.id || row.contactId || '',\n    name: row.name || row.firstname || row.Nombre || '',\n    company: row.company || row.empresa || '',\n    email,\n  });\n}\n\nconst dupes = Object.entries(map)\n  .filter(([, rows]) => rows.length > 1)\n  .map(([email, rows]) => ({\n    email,\n    count: rows.length,\n    ids: rows.map((r) => r.id).filter(Boolean).join(', '),\n    names: rows.map((r) => r.name || '(sin nombre)').join(' | '),\n  }))\n  .slice(0, 40);\n\nconst summaryLines = dupes.length\n  ? dupes.map((d) => `• \\`${d.email}\\` ×${d.count} — ids: ${d.ids || 'n/a'}`).join('\\n')\n  : '(ningún duplicado por email en este lote)';\n\nreturn [{\n  json: {\n    statusCode,\n    apiOk: statusCode >= 200 && statusCode < 300,\n    contactCount: list.length,\n    dupeCount: dupes.length,\n    hasDupes: dupes.length > 0,\n    dupeSample: dupes,\n    summaryLines,\n    slackWebhookUrl: cfg.slackWebhookUrl,\n    crmSearchUrl: cfg.crmSearchUrl,\n    crmApiKey: cfg.crmApiKey,\n    checkedAt: cfg.checkedAt,\n    firstDupeEmail: dupes[0]?.email || '',\n  },\n}];"
      }
    },
    {
      "id": "dup-if-api",
      "name": "¿GET CRM OK?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1200,
        560
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "dup-cond-api",
              "leftValue": "={{ $json.apiOk }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      }
    },
    {
      "id": "dup-if-dupes",
      "name": "¿Hay posibles duplicados?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1500,
        300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "dup-cond-dupes",
              "leftValue": "={{ $json.hasDupes }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      }
    },
    {
      "id": "dup-search",
      "name": "HTTP search heurística (opcional)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1800,
        40
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ $json.crmSearchUrl + encodeURIComponent($json.firstDupeEmail) }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $json.crmApiKey }}"
            }
          ]
        },
        "options": {
          "timeout": 15000,
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      }
    },
    {
      "id": "dup-slack",
      "name": "Slack — lista posibles duplicados",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2100,
        40
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $('Normalizar + agrupar duplicados').item.json.slackWebhookUrl }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: $json.slackText || 'Actualización Logixb2b' }) }}",
        "options": {
          "timeout": 10000
        }
      }
    },
    {
      "id": "dup-clean",
      "name": "Sin duplicados — OK",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1800,
        560
      ],
      "parameters": {}
    },
    {
      "id": "dup-fail",
      "name": "Slack — fallo GET CRM",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1500,
        820
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $json.slackWebhookUrl }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: ':warning: *Dedupe CRM* — fallo GET contactos\\nHTTP: ' + $json.statusCode + '\\nHora: ' + $json.checkedAt + ' (Europe/Madrid)' }) }}",
        "options": {
          "timeout": 10000
        }
      }
    }
  ],
  "connections": {
    "Diario 02:00": {
      "main": [
        [
          {
            "node": "Config dedupe",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config dedupe": {
      "main": [
        [
          {
            "node": "GET contactos CRM",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GET contactos CRM": {
      "main": [
        [
          {
            "node": "Normalizar + agrupar duplicados",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalizar + agrupar duplicados": {
      "main": [
        [
          {
            "node": "¿GET CRM OK?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "¿GET CRM OK?": {
      "main": [
        [
          {
            "node": "¿Hay posibles duplicados?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack — fallo GET CRM",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "¿Hay posibles duplicados?": {
      "main": [
        [
          {
            "node": "HTTP search heurística (opcional)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Sin duplicados — OK",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP search heurística (opcional)": {
      "main": [
        [
          {
            "node": "Slack — lista posibles duplicados",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
