{
  "name": "Logixb2b — Alertas de oportunidades calientes en CRM",
  "meta": {
    "templateCredsSetupCompleted": false,
    "description": "Webhook Lead CRM → Config alerta (umbrales, Slack, horario Europe/Madrid) → Code (detecta stage/importe/probabilidad/inactividad + dedupe) → IF avisar → Slack → Respuesta webhook."
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Europe/Madrid"
  },
  "active": false,
  "pinData": {},
  "tags": [
    {
      "name": "Logixb2b"
    },
    {
      "name": "recursos"
    },
    {
      "name": "crm"
    },
    {
      "name": "alertas"
    }
  ],
  "nodes": [
    {
      "id": "sticky-instrucciones-logix",
      "name": "INSTRUCCIONES (conectar + VPS)",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -480,
        40
      ],
      "parameters": {
        "content": "## INSTRUCCIONES — Alertas de oportunidades calientes en CRM\n\n**Recurso:** `/recursos/alertas-oportunidades-calientes-crm`\n**Guía:** /como-crear-alertas-automaticas-oportunidades-calientes-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- **Webhook Lead** (`webhook`): sin credencial. Tras Active, copia **URL de producción** (HTTPS del VPS/Cloud). El emisor (formulario, CRM, bot) debe POST a esa URL.\n- **Config alerta** (`Set` Config): **no lleva credencial**. Aquí van URLs/placeholders (`REEMPLAZA_*`, `example.com`, `TU-CRM`). Sustituye todos antes de Active.\n- **Detectar oportunidad caliente** (`Code`): sin credencial. Revisa límites/umbrales en el JS; no hardcodees secretos.\n- **¿Avisar?** (`IF`): sin credencial. Comprueba la condición (email, umbral, over_limit, etc.) con datos reales de prueba.\n- **Aviso Slack** (`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- **Responder (avisado)** (`respondToWebhook`): sin credencial. Tras Active, copia **URL de producción** (HTTPS del VPS/Cloud). El emisor (formulario, CRM, bot) debe POST a esa URL.\n- **Responder (sin aviso)** (`respondToWebhook`): sin credencial. Tras Active, copia **URL de producción** (HTTPS del VPS/Cloud). El emisor (formulario, CRM, bot) debe POST a esa URL.\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": "alerta-webhook",
      "name": "Webhook Lead",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        300
      ],
      "webhookId": "logix-alerta-oportunidad-caliente",
      "parameters": {
        "httpMethod": "POST",
        "path": "alerta-oportunidad-caliente",
        "responseMode": "responseNode",
        "options": {}
      }
    },
    {
      "id": "alerta-config",
      "name": "Config alerta",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        300,
        300
      ],
      "parameters": {
        "mode": "manual",
        "duplicateItem": false,
        "assignments": {
          "assignments": [
            {
              "id": "alerta-c1",
              "name": "slackWebhookUrl",
              "value": "https://example.com/aviso-equipo-slack",
              "type": "string"
            },
            {
              "id": "alerta-c2",
              "name": "timezone",
              "value": "Europe/Madrid",
              "type": "string"
            },
            {
              "id": "alerta-c3",
              "name": "workHourStart",
              "value": 8,
              "type": "number"
            },
            {
              "id": "alerta-c4",
              "name": "workHourEnd",
              "value": 20,
              "type": "number"
            },
            {
              "id": "alerta-c5",
              "name": "amountThreshold",
              "value": 15000,
              "type": "number"
            },
            {
              "id": "alerta-c6",
              "name": "probabilityJumpThreshold",
              "value": 20,
              "type": "number"
            },
            {
              "id": "alerta-c7",
              "name": "inactivityDaysThreshold",
              "value": 15,
              "type": "number"
            },
            {
              "id": "alerta-c8",
              "name": "hotStages",
              "value": "[\"propuesta-enviada\",\"negociacion\",\"contrato\"]",
              "type": "string"
            },
            {
              "id": "alerta-c9",
              "name": "rawBody",
              "value": "={{ $json.body || $json }}",
              "type": "object"
            },
            {
              "id": "alerta-c10",
              "name": "receivedAt",
              "value": "={{ $now.setZone('Europe/Madrid').toFormat('yyyy-MM-dd HH:mm') }}",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "alerta-detect",
      "name": "Detectar oportunidad caliente",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        600,
        300
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "language": "javaScript",
        "jsCode": "const cfg = item.json;\nconst raw = cfg.rawBody || {};\nconst norm = (v) => (v == null ? '' : String(v).trim());\n\nconst dealId = norm(raw.dealId || raw.id || raw.dealID);\nconst dealName = norm(raw.dealName || raw.name || raw.title || 'Sin nombre');\nconst stage = norm(raw.stage || raw.dealstage || raw.status).toLowerCase();\nconst previousStage = norm(raw.previousStage || raw.previousDealstage || raw.previousStatus).toLowerCase();\nconst amount = Number(raw.amount || raw.dealValue || raw.value || 0);\nconst probability = Number(raw.probability != null ? raw.probability : (raw.probabilidad != null ? raw.probabilidad : NaN));\nconst previousProbability = Number(raw.previousProbability != null ? raw.previousProbability : NaN);\nconst previousLastActivityAt = raw.previousLastActivityAt || raw.previousActivityDate || null;\nconst ownerEmail = norm(raw.ownerEmail || raw.owner || raw.hs_owner_email);\nconst leadCompany = norm(raw.company || raw.empresa);\n\nlet hotStages = [];\ntry {\n  hotStages = JSON.parse(cfg.hotStages || '[]');\n} catch (e) {\n  hotStages = [];\n}\n\nconst reasons = [];\n\nconst stageHot = Boolean(stage) && hotStages.includes(stage) && stage !== previousStage;\nif (stageHot) reasons.push(`cambio de stage a '${stage}'`);\n\nconst amountThreshold = Number(cfg.amountThreshold || 0);\nconst amountHot = amount > 0 && amount >= amountThreshold;\nif (amountHot) reasons.push(`importe ${amount} por encima del umbral ${amountThreshold}`);\n\nconst probabilityJumpThreshold = Number(cfg.probabilityJumpThreshold || 999);\nconst probJump = Number.isFinite(probability) && Number.isFinite(previousProbability) ? probability - previousProbability : 0;\nconst probabilityHot = probJump >= probabilityJumpThreshold;\nif (probabilityHot) reasons.push(`probabilidad subio ${probJump} puntos`);\n\nlet inactivityDays = 0;\nlet inactivityHot = false;\nconst inactivityDaysThreshold = Number(cfg.inactivityDaysThreshold || 999999);\nif (previousLastActivityAt) {\n  const prevDate = new Date(previousLastActivityAt);\n  const nowDate = new Date();\n  if (!isNaN(prevDate.getTime())) {\n    inactivityDays = Math.floor((nowDate - prevDate) / 86400000);\n    inactivityHot = inactivityDays >= inactivityDaysThreshold;\n  }\n}\nif (inactivityHot) reasons.push(`reactivacion tras ${inactivityDays} dias sin actividad`);\n\nconst isHot = stageHot || amountHot || probabilityHot || inactivityHot;\n\nconst staticData = $getWorkflowStaticData('global');\nstaticData.notified = staticData.notified || {};\nconst notifyKey = [dealId, stage, Math.round(amount), Math.round(probability || 0)].join('::');\nconst alreadyNotified = staticData.notified[notifyKey] === true;\n\nconst tz = cfg.timezone || 'Europe/Madrid';\nconst hourFormatter = new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', hour12: false });\nconst currentHour = Number(hourFormatter.format(new Date()));\nconst workHourStart = Number(cfg.workHourStart != null ? cfg.workHourStart : 8);\nconst workHourEnd = Number(cfg.workHourEnd != null ? cfg.workHourEnd : 20);\nconst inWorkHours = currentHour >= workHourStart && currentHour < workHourEnd;\n\nconst shouldNotify = isHot && !alreadyNotified && inWorkHours;\nif (shouldNotify) {\n  staticData.notified[notifyKey] = true;\n}\n\nreturn {\n  json: {\n    ...cfg,\n    dealId,\n    dealName,\n    stage,\n    previousStage,\n    amount,\n    probability,\n    ownerEmail,\n    leadCompany,\n    isHot,\n    alreadyNotified,\n    inWorkHours,\n    currentHourMadrid: currentHour,\n    inactivityDays,\n    shouldNotify,\n    reasons,\n    reasonText: reasons.length ? reasons.join('; ') : 'sin senales de oportunidad caliente, o fuera de horario/duplicado'\n  }\n};"
      }
    },
    {
      "id": "alerta-if",
      "name": "¿Avisar?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        900,
        300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "alerta-if-1",
              "leftValue": "={{ $json.shouldNotify }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "alerta-slack",
      "name": "Aviso Slack",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1200,
        180
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $('Config alerta').item.json.slackWebhookUrl }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: 'Oportunidad caliente detectada\\n' + $('Detectar oportunidad caliente').item.json.dealName + ' (' + ($('Detectar oportunidad caliente').item.json.leadCompany || 'sin empresa') + ')\\nOwner: ' + ($('Detectar oportunidad caliente').item.json.ownerEmail || 'sin owner') + '\\nMotivo: ' + $('Detectar oportunidad caliente').item.json.reasonText + '\\nImporte: ' + $('Detectar oportunidad caliente').item.json.amount + ' | Probabilidad: ' + $('Detectar oportunidad caliente').item.json.probability + '\\n' + $('Detectar oportunidad caliente').item.json.receivedAt }) }}",
        "options": {
          "timeout": 20000
        }
      }
    },
    {
      "id": "alerta-respond-hot",
      "name": "Responder (avisado)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1500,
        180
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: true, notified: true, dealId: $('Detectar oportunidad caliente').item.json.dealId, ownerEmail: $('Detectar oportunidad caliente').item.json.ownerEmail, reasons: $('Detectar oportunidad caliente').item.json.reasons, receivedAt: $('Detectar oportunidad caliente').item.json.receivedAt }) }}",
        "options": {}
      }
    },
    {
      "id": "alerta-respond-normal",
      "name": "Responder (sin aviso)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1200,
        420
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: true, notified: false, isHot: $('Detectar oportunidad caliente').item.json.isHot, alreadyNotified: $('Detectar oportunidad caliente').item.json.alreadyNotified, inWorkHours: $('Detectar oportunidad caliente').item.json.inWorkHours, reasonText: $('Detectar oportunidad caliente').item.json.reasonText }) }}",
        "options": {}
      }
    }
  ],
  "connections": {
    "Webhook Lead": {
      "main": [
        [
          {
            "node": "Config alerta",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config alerta": {
      "main": [
        [
          {
            "node": "Detectar oportunidad caliente",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Detectar oportunidad caliente": {
      "main": [
        [
          {
            "node": "¿Avisar?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "¿Avisar?": {
      "main": [
        [
          {
            "node": "Aviso Slack",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Responder (sin aviso)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aviso Slack": {
      "main": [
        [
          {
            "node": "Responder (avisado)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
