{
  "name": "Propulsar \u2014 Content Engine v3",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "propulsar-content",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "webhook-trigger",
      "name": "\ud83c\udfaf Webhook Trigger",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        208,
        304
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={ \"status\": \"received\" }",
        "options": {}
      },
      "id": "respond-wizard",
      "name": "\u2705 Responder al Wizard",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [
        432,
        160
      ]
    },
    {
      "parameters": {
        "modelId": {
          "__rl": true,
          "value": "gpt-4o",
          "mode": "list",
          "cachedResultName": "GPT-4O"
        },
        "messages": {
          "values": [
            {
              "content": "Eres el director creativo de Propulsar.ai, agencia de automatizaciones IA para PYMEs.\n\nTu tarea es generar prompts de imagen para cada slide de un carrusel de Instagram/Facebook, m\u00e1s los captions para ambas plataformas.\n\nPRIMERO elige la estructura de carrusel m\u00e1s adecuada para el tema y tipo:\n- narrativo: cuenta una historia con arco (inicio \u2192 desarrollo \u2192 resoluci\u00f3n)\n- listicle: lista de tips, herramientas o beneficios numerados\n- paso-a-paso: instrucciones secuenciales para lograr algo\n- antes-despu\u00e9s: contraste entre una situaci\u00f3n actual y la transformaci\u00f3n\n- pregunta-respuesta: hook con pregunta, slides respondiendo, CTA final\n\nIDENTIDAD VISUAL PROPULSAR (aplicar en CADA prompt sin excepci\u00f3n):\n- Fondo oscuro profundo: #1a1a2e\n- Gradiente principal: p\u00farpura #6B46C1 \u2192 magenta #EC4899\n- Tipograf\u00eda bold sans-serif en espa\u00f1ol, legible, prominente\n- Formato cuadrado 1:1\n- Est\u00e9tica profesional tech/AI, alto contraste, ultra-detailed, 4K quality\n\nREGLAS DE TEXTO EN IMAGEN (CR\u00cdTICO):\n1. El texto_overlay es el texto EXACTO que aparecer\u00e1 visible en la imagen. M\u00e1ximo 6-8 palabras. Si necesit\u00e1s m\u00e1s, divid\u00ed en m\u00e1s slides.\n2. En el campo prompt, el texto en espa\u00f1ol va PRIMERO entre comillas dobles, usando el patr\u00f3n: Text says: \"[texto_overlay exacto]\". Despu\u00e9s describ\u00ed el visual.\n3. NUNCA incluir texto en ingl\u00e9s visible en la imagen. Todo texto visible debe ser en espa\u00f1ol castellano.\n4. Prohibido usar texto lorem ipsum, texto decorativo ilegible, o texto de relleno.\n\nREGLAS GENERALES:\n1. Cada campo prompt debe ser COMPLETAMENTE autocontenido con TODAS las restricciones de estilo. Prohibido usar \"same as above\", \"idem\" o referencias a otros slides.\n2. El array slides debe tener EXACTAMENTE el n\u00famero de slides solicitados.\n3. Los prompt van en ingl\u00e9s (excepto el texto visible que va en espa\u00f1ol entre comillas).\n4. Los texto_overlay van en espa\u00f1ol.\n5. Respond\u00e9 con JSON puro sin markdown, sin bloques ```json.\n\nFORMATO DE RESPUESTA:\n{\n  \"estructura\": \"narrativo|listicle|paso-a-paso|antes-despu\u00e9s|pregunta-respuesta\",\n  \"instagram_caption\": \"caption completo con hook <125 chars, 150-300 palabras, p\u00e1rrafos cortos, 8-12 hashtags, m\u00e1x 3 emojis al inicio de l\u00ednea, CTA claro\",\n  \"facebook_caption\": \"caption 100-200 palabras, 3-5 hashtags, pregunta para comentarios al final, CTA\",\n  \"slides\": [\n    {\n      \"slide_num\": 1,\n      \"texto_overlay\": \"m\u00e1x 6-8 palabras en espa\u00f1ol\",\n      \"prompt\": \"Text says: \\\"[texto_overlay exacto aqu\u00ed]\\\". Dark background #1a1a2e, bold white sans-serif typography centered, purple #6B46C1 to magenta #EC4899 gradient glow, professional AI/tech aesthetic, ultra-detailed, crisp text, square 1:1, no other text visible. [descripci\u00f3n visual espec\u00edfica del slide].\"\n    }\n  ]\n}\n\nEJEMPLO DE UN BUEN PROMPT:\nText says: \"Automatiza tu WhatsApp\". Dark background #1a1a2e, bold white sans-serif typography centered, purple #6B46C1 to magenta #EC4899 gradient glow behind text, professional AI/tech aesthetic, minimalist icons of chat bubbles and automation gears, ultra-detailed, crisp text, square 1:1, no other text visible.",
              "role": "system"
            },
            {
              "content": "=Tema: \"{{ $json.body.topic }}\"\nTipo: {{ $json.body.type }}\n{% if $json.body.angle %}\u00c1ngulo: {{ $json.body.angle }}\n{% endif %}Slides requeridos: {{ $json.body.num_images }}\n\nElige la estructura del carrusel apropiada y genera EXACTAMENTE {{ $json.body.num_images }} prompts de imagen, m\u00e1s los captions para Instagram y Facebook."
            }
          ]
        },
        "options": {
          "temperature": 0.5
        }
      },
      "id": "openai-carousel",
      "name": "\ud83c\udfa0 GPT-4o \u2014 Prompts Carrusel",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 1.4,
      "position": [
        640,
        528
      ],
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const raw = $input.first().json.message.content;\nconst b = $('\ud83c\udfaf Webhook Trigger').first().json.body;\n\nlet parsed;\ntry {\n  parsed = JSON.parse(raw.replace(/```json\\n?/g,'').replace(/```\\n?/g,'').trim());\n} catch(e) {\n  throw new Error('JSON inv\u00e1lido de GPT-4o (carrusel): ' + raw.substring(0,200));\n}\n\nconst slides = parsed.slides || [];\n\nif (slides.length !== b.num_images) {\n  console.warn(`\u26a0 GPT-4o devolvi\u00f3 ${slides.length} slides, se esperaban ${b.num_images}`);\n}\n\nreturn [{\n  json: {\n    instagram:       { caption: parsed.instagram_caption || '' },\n    facebook:        { caption: parsed.facebook_caption || '' },\n    topic:           b.topic,\n    type:            b.type,\n    angle:           b.angle || null,\n    platforms:       b.platforms,\n    image_model:     'ideogram',\n    fal_model_id:    null,\n    has_own_image:   false,\n    image_url:       null,\n    has_text_in_image: true,\n    approval_number: b.approval_number,\n    timestamp:       b.timestamp,\n    publish_at:      b.publish_at || 'now',\n    format:          'carousel',\n    num_images:      b.num_images,\n    estructura:      parsed.estructura || 'listicle',\n    image_prompts:   slides.map(s => ({\n      slide_num:     s.slide_num,\n      texto_overlay: s.texto_overlay,\n      prompt:        s.prompt,\n    })),\n  }\n}];"
      },
      "id": "parse-carousel",
      "name": "\ud83d\udd27 Parsear prompts carrusel",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        864,
        528
      ]
    },
    {
      "parameters": {
        "jsCode": "const data = $input.first().json;\nconst igCaption = data.instagram?.caption || data.instagram_caption || '';\nconst lines = igCaption.split('\\n');\nconst hashtagLines = lines.filter(l => l.trim().startsWith('#'));\nconst cleanLines = lines.filter(l => !l.trim().startsWith('#'));\nconst cleanCaption = cleanLines.join('\\n').trimEnd();\nconst hashtagBlock = hashtagLines.join(' ').trim();\nreturn [{\n  json: {\n    ...data,\n    instagram: { ...(data.instagram || {}), caption: cleanCaption },\n    instagram_caption: cleanCaption,\n    hashtag_block: hashtagBlock,\n  }\n}];"
      },
      "id": "extract-hashtags-carousel",
      "name": "\u2702\ufe0f Extract Hashtags (Carousel)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        976,
        528
      ]
    },
    {
      "parameters": {
        "jsCode": "const data = $input.first().json;\nconst prompts = data.image_prompts;\n\nreturn prompts.map(slide => ({\n  json: {\n    slide_num:       slide.slide_num,\n    texto_overlay:   slide.texto_overlay,\n    prompt:          slide.prompt,\n    approval_number: data.approval_number,\n    num_images:      data.num_images,\n    topic:           data.topic,\n    type:            data.type,\n  }\n}));"
      },
      "id": "explode-carousel-slides",
      "name": "\ud83c\udfa0 Explode Slides",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1088,
        528
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.ideogram.ai/generate",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Api-Key",
              "value": "={{ $env.IDEOGRAM_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"image_request\": {\n    \"prompt\": \"{{ $json.prompt.replaceAll('\"', '\\\\\"') }}\",\n    \"aspect_ratio\": \"ASPECT_1_1\",\n    \"model\": \"V_2_TURBO\",\n    \"magic_prompt_option\": \"OFF\",\n    \"style_type\": \"DESIGN\"\n  }\n}",
        "options": {}
      },
      "id": "ideogram-slide",
      "name": "\ud83d\udd24 Ideogram \u2014 Slide",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1520,
        528
      ],
      "notes": "Ideogram v2 per carousel slide \u2014 called once per slide via SplitInBatches loop"
    },
    {
      "parameters": {
        "modelId": {
          "__rl": true,
          "value": "gpt-4o",
          "mode": "list",
          "cachedResultName": "GPT-4O"
        },
        "messages": {
          "values": [
            {
              "content": "Eres el redactor de contenido de Propulsar.ai, agencia de automatizaciones IA para PYMEs.\n\nVoz: profesional pero accesible, directo, espa\u00f1ol latinoamericano (tuteo).\nSin buzzwords. Prohibido: revolutionario, disruptivo, sinergia, delve, unleash.\n\nDevolv\u00e9 SOLO JSON v\u00e1lido, sin markdown:\n{\n  \"instagram\": {\n    \"caption\": \"post completo con hashtags al final\",\n    \"image_prompt\": \"prompt en ingl\u00e9s para generaci\u00f3n de imagen, dark background purple-magenta gradients, professional social media\"\n  },\n  \"facebook\": {\n    \"caption\": \"post completo (m\u00e1s corto, 3-5 hashtags)\"\n  }\n}",
              "role": "system"
            },
            {
              "content": "=Genera un post {{ $json.body.type }} sobre: \"{{ $json.body.topic }}\"\n\n{% if $json.body.angle %}\u00c1ngulo: {{ $json.body.angle }}{% endif %}\n{% if $json.body.has_text_in_image %}IMPORTANTE: El image_prompt debe incluir texto visible espec\u00edfico relevante al tema, ya que se usar\u00e1 Ideogram (excelente para texto en imagen).{% endif %}\n\nPlataformas: {{ $json.body.platforms.join(', ') }}\n\nInstagram: hook <125 chars, 150-300 palabras, p\u00e1rrafos cortos, 8-12 hashtags, m\u00e1x 3 emojis, CTA.\nFacebook: 100-200 palabras, 3-5 hashtags, pregunta final para comentarios, CTA."
            }
          ]
        },
        "options": {
          "temperature": 0.5
        }
      },
      "id": "openai-text",
      "name": "\ud83e\udd16 GPT-4o \u2014 Texto",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 1.4,
      "position": [
        432,
        368
      ],
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const raw = $input.first().json.message.content;\nlet parsed;\ntry {\n  parsed = JSON.parse(raw.replace(/```json\\n?/g,'').replace(/```\\n?/g,'').trim());\n} catch(e) {\n  throw new Error('JSON inv\u00e1lido de OpenAI: ' + raw.substring(0,200));\n}\nconst b = $('\ud83c\udfaf Webhook Trigger').first().json.body;\nreturn [{ json: {\n  instagram:       parsed.instagram || null,\n  facebook:        parsed.facebook  || null,\n  image_prompt:    parsed.instagram?.image_prompt || '',\n  platforms:       b.platforms,\n  topic:           b.topic,\n  type:            b.type,\n  angle:           b.angle || null,\n  image_model:     b.image_model || 'flux',\n  fal_model_id:    b.fal_model_id || 'fal-ai/flux-pro/v1.1',\n  has_own_image:   b.has_own_image || false,\n  image_url:       b.image_url || null,\n  has_text_in_image: b.has_text_in_image || false,\n  approval_number: b.approval_number,\n  timestamp:       b.timestamp,\n  publish_at:      b.publish_at || 'now'\n}}];"
      },
      "id": "parse-content",
      "name": "\ud83d\udd27 Parsear contenido",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        640,
        368
      ]
    },
    {
      "parameters": {
        "jsCode": "const data = $input.first().json;\nconst igCaption = data.instagram?.caption || data.instagram_caption || '';\nconst lines = igCaption.split('\\n');\nconst hashtagLines = lines.filter(l => l.trim().startsWith('#'));\nconst cleanLines = lines.filter(l => !l.trim().startsWith('#'));\nconst cleanCaption = cleanLines.join('\\n').trimEnd();\nconst hashtagBlock = hashtagLines.join(' ').trim();\nreturn [{\n  json: {\n    ...data,\n    instagram: { ...(data.instagram || {}), caption: cleanCaption },\n    instagram_caption: cleanCaption,\n    hashtag_block: hashtagBlock,\n  }\n}];"
      },
      "id": "extract-hashtags-single",
      "name": "\u2702\ufe0f Extract Hashtags (Single)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        752,
        368
      ]
    },
    {
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{ String($json.has_own_image) }}",
              "value2": "true"
            }
          ]
        }
      },
      "id": "check-own-image",
      "name": "\ud83d\uddbc\ufe0f \u00bfImagen propia?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        864,
        368
      ]
    },
    {
      "parameters": {
        "jsCode": "return [{ json: { ...$input.first().json, final_image_url: $input.first().json.image_url } }];"
      },
      "id": "use-own-image",
      "name": "\ud83d\udcce Imagen propia",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1088,
        208
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://fal.run/fal-ai/flux-pro/v1.1",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Key {{ $env.FAL_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"prompt\": \"{{ $json.image_prompt }} \u2014 style: dark background #1a1a2e, purple to magenta gradient accents, professional high-quality social media graphic, ultra detailed, 4K\",\n  \"image_size\": \"square_hd\",\n  \"num_inference_steps\": 28,\n  \"guidance_scale\": 3.5,\n  \"num_images\": 1,\n  \"enable_safety_checker\": true\n}",
        "options": {}
      },
      "id": "flux-generate",
      "name": "\u26a1 Flux 2 Pro (FAL.AI)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1312,
        304
      ],
      "notes": "FAL model: fal-ai/flux-pro/v1.1\nCosto: ~$0.03/img\nFortaleza: fotorrealismo premium"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.ideogram.ai/generate",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Api-Key",
              "value": "={{ $env.IDEOGRAM_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"image_request\": {\n    \"prompt\": \"{{ $json.image_prompt.replaceAll('\"', '\\\\\"') }} \u2014 professional design, dark background #1a1a2e, purple and magenta gradient elements, bold readable typography, social media post\",\n    \"aspect_ratio\": \"ASPECT_1_1\",\n    \"model\": \"V_2_TURBO\",\n    \"magic_prompt_option\": \"OFF\",\n    \"style_type\": \"DESIGN\"\n  }\n}",
        "options": {}
      },
      "id": "ideogram-generate",
      "name": "\ud83d\udd24 Ideogram v3",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1312,
        448
      ],
      "notes": "Costo: ~$0.06/img\nFortaleza: texto legible en imagen (90-95% precisi\u00f3n)\nRequiere IDEOGRAM_API_KEY"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://fal.run/fal-ai/nano-banana",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Key {{ $env.FAL_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"prompt\": \"{{ $json.image_prompt }} \u2014 ultra high quality, dark background #1a1a2e, purple magenta gradient accents, professional social media graphic, 4K resolution, photorealistic\",\n  \"num_images\": 1,\n  \"aspect_ratio\": \"1:1\",\n  \"output_format\": \"png\",\n  \"safety_tolerance\": \"4\"\n}",
        "options": {}
      },
      "id": "nano-banana-generate",
      "name": "\ud83c\udf4c Nano Banana Pro (FAL.AI)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1312,
        592
      ],
      "notes": "FAL model: fal-ai/nano-banana (Gemini 3 Pro Image)\nCosto: ~$0.15/img\nFortaleza: razonamiento visual, 4K, alto impacto\nMisma FAL_API_KEY que Flux"
    },
    {
      "parameters": {
        "jsCode": "// Normalizar la URL de imagen de cualquier proveedor\nconst content = $('\ud83d\udd27 Parsear contenido').first().json;\nconst imageData = $input.first().json;\nconst model = content.image_model;\n\nlet finalUrl = null;\n\nif (model === 'flux' || model === 'nanoBanana') {\n  // FAL.AI devuelve: { images: [{ url }] } o { image: { url } }\n  finalUrl = imageData.images?.[0]?.url\n          || imageData.image?.url\n          || null;\n} else if (model === 'ideogram') {\n  // Ideogram devuelve: { data: [{ url }] }\n  finalUrl = imageData.data?.[0]?.url || null;\n} else if (model === 'custom') {\n  finalUrl = content.image_url;\n}\n\nif (!finalUrl) {\n  console.warn('\u26a0 No se pudo extraer URL de imagen. Respuesta:', JSON.stringify(imageData).substring(0, 300));\n}\n\nreturn [{ json: { ...content, final_image_url: finalUrl, image_provider_response: imageData } }];"
      },
      "id": "normalize-image",
      "name": "\ud83d\udd17 Normalizar URL imagen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1520,
        448
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.SUPABASE_URL }}/rest/v1/content_sessions",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "apikey",
              "value": "={{ $env.SUPABASE_ANON_KEY }}"
            },
            {
              "name": "Authorization",
              "value": "=Bearer {{ $env.SUPABASE_ANON_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Prefer",
              "value": "return=representation"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ session_id: 'propulsar_' + Date.now(), approval_number: $json.approval_number, topic: $json.topic, type: $json.type, angle: $json.angle || null, platforms: $json.platforms || [], image_model: $json.image_model, image_url: $json.image_url || null, final_image_url: $json.final_image_url, instagram_caption: ($json.instagram && $json.instagram.caption) || null, facebook_caption: ($json.facebook && $json.facebook.caption) || null, status: 'pending', publish_at: $json.publish_at || 'now' }) }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "save-session-supabase",
      "name": "\ud83d\udcbe Guardar sesi\u00f3n Supabase",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1632,
        600
      ],
      "notes": "Inserts pending approval session into content_sessions (flat schema \u2014 instagram_caption/facebook_caption strings, not nested). Built as HTTP Request (not Code) because n8n task runner sandbox blocks require('https'), global fetch, and $helpers.httpRequest. Response = array of the inserted row."
    },
    {
      "parameters": {
        "mode": "manual",
        "duplicateItem": false,
        "assignments": {
          "assignments": [
            {
              "id": "a1",
              "name": "topic",
              "type": "string",
              "value": "={{ $('\ud83d\udd17 Normalizar URL imagen').item.json.topic }}"
            },
            {
              "id": "a2",
              "name": "type",
              "type": "string",
              "value": "={{ $('\ud83d\udd17 Normalizar URL imagen').item.json.type }}"
            },
            {
              "id": "a3",
              "name": "angle",
              "type": "string",
              "value": "={{ $('\ud83d\udd17 Normalizar URL imagen').item.json.angle || '' }}"
            },
            {
              "id": "a4",
              "name": "platforms",
              "type": "array",
              "value": "={{ $('\ud83d\udd17 Normalizar URL imagen').item.json.platforms }}"
            },
            {
              "id": "a5",
              "name": "image_model",
              "type": "string",
              "value": "={{ $('\ud83d\udd17 Normalizar URL imagen').item.json.image_model }}"
            },
            {
              "id": "a6",
              "name": "final_image_url",
              "type": "string",
              "value": "={{ $('\ud83d\udd17 Normalizar URL imagen').item.json.final_image_url }}"
            },
            {
              "id": "a7",
              "name": "approval_number",
              "type": "string",
              "value": "={{ $('\ud83d\udd17 Normalizar URL imagen').item.json.approval_number }}"
            },
            {
              "id": "a8",
              "name": "instagram",
              "type": "object",
              "value": "={{ $('\ud83d\udd17 Normalizar URL imagen').item.json.instagram }}"
            },
            {
              "id": "a9",
              "name": "facebook",
              "type": "object",
              "value": "={{ $('\ud83d\udd17 Normalizar URL imagen').item.json.facebook }}"
            },
            {
              "id": "a10",
              "name": "session_id",
              "type": "string",
              "value": "={{ $json.session_id }}"
            },
            {
              "id": "a11",
              "name": "supabase_row_id",
              "type": "string",
              "value": "={{ $json.id }}"
            }
          ]
        },
        "includeOtherFields": false,
        "options": {}
      },
      "id": "reattach-session-data",
      "name": "\ud83d\udd17 Re-attach session data",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1744,
        600
      ],
      "notes": "Supabase HTTP Request replaced the item with the inserted row (flat schema \u2014 no nested instagram/facebook objects). Re-attach the pre-insert data via cross-ref to Normalizar URL imagen so downstream nodes (Preparar mensaje WA, etc.) keep working with the nested object shape. Adds session_id + supabase_row_id from the insert response."
    },
    {
      "parameters": {
        "jsCode": "// Handle single-post and carousel \u2014 read from correct upstream node\nlet d;\ntry {\n  d = $('\ud83d\uddc2\ufe0f Collect Image URLs').first().json;\n} catch(e1) {\n  try {\n    d = $('\ud83d\udd17 Normalizar URL imagen').first().json;\n  } catch(e2) {\n    d = $input.first().json;\n  }\n}\n\nconst isCarousel = d.format === 'carousel';\n\nconst typeEmoji  = { educational:'\ud83d\udcda', authority:'\ud83c\udfa4', case_study:'\ud83c\udfc6' };\nconst typeLabel  = { educational:'Educativo', authority:'Autoridad', case_study:'Caso de \u00e9xito' };\nconst modelInfo  = {\n  flux:       '\u26a1 Flux 2 Pro',\n  ideogram:   '\ud83d\udd24 Ideogram v3',\n  nanoBanana: '\ud83c\udf4c Nano Banana Pro',\n  custom:     '\ud83d\udcce Imagen propia',\n};\n\nconst igPreview = d.instagram?.caption?.substring(0, 280) || 'N/A';\nconst fbPreview = d.facebook?.caption?.substring(0, 180) || 'N/A';\nconst igDots = (d.instagram?.caption?.length || 0) > 280 ? '...' : '';\nconst fbDots = (d.facebook?.caption?.length || 0)  > 180 ? '...' : '';\n\nconst imageStatus = isCarousel\n  ? `\ud83c\udfa0 ${d.image_urls?.length || 0} im\u00e1genes generadas (carrusel)`\n  : `\ud83d\uddbc\ufe0f ${d.final_image_url ? '\u2705 Imagen generada' : '\u26a0\ufe0f Sin imagen'}`;\n\nconst formatLine = isCarousel\n  ? `\\n\ud83c\udfa0 *Formato:* Carrusel (${d.num_images} slides)`\n  : '';\n\nconst msg = `\ud83d\ude80 *PROPULSAR CONTENT ENGINE*\n\n${typeEmoji[d.type] || '\ud83d\udcdd'} *Tipo:* ${typeLabel[d.type] || d.type}\n\ud83d\udccc *Tema:* ${d.topic}\n${d.angle ? `\ud83c\udfaf *\u00c1ngulo:* ${d.angle}\\n` : ''}\ud83d\udcf1 *Redes:* ${(d.platforms || []).join(', ')}\n\ud83d\uddbc\ufe0f *Imagen:* ${modelInfo[d.image_model] || d.image_model}${formatLine}\n\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n\ud83d\udcf8 *INSTAGRAM:*\n${igPreview}${igDots}\n\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n\ud83d\udc65 *FACEBOOK:*\n${fbPreview}${fbDots}\n\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n${imageStatus}\n\n*\u00bfPublicar?*\n\u2705 *SI* para publicar\n\u274c *NO* para cancelar`;\n\nreturn [{ json: { ...d, whatsapp_message: msg, session_id: `propulsar_${Date.now()}` } }];"
      },
      "id": "prepare-whatsapp",
      "name": "\ud83d\udcf1 Preparar mensaje WA",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1744,
        448
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.ycloud.com/v2/whatsapp/messages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-API-Key",
              "value": "={{ $env.YCLOUD_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ \"from\": $env.YCLOUD_WHATSAPP_NUMBER, \"to\": $json.approval_number, \"type\": \"text\", \"text\": { \"body\": $json.whatsapp_message } }) }}",
        "options": {}
      },
      "id": "send-whatsapp",
      "name": "\ud83d\udce4 Enviar WhatsApp",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1968,
        448
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "propulsar-whatsapp-reply",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "webhook-reply",
      "name": "\ud83d\udce8 Webhook \u2014 Reply WA",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        2192,
        448
      ],
      "notes": "Configurar esta URL en YCloud \u2192 Webhooks \u2192 Incoming Messages"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={ \"status\": \"ok\" }",
        "options": {}
      },
      "id": "respond-ycloud",
      "name": "\u2705 Responder YCloud",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [
        2400,
        288
      ]
    },
    {
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{ $json.body?.whatsappInboundMessage?.text?.body?.trim().toUpperCase() }}",
              "value2": "SI"
            }
          ]
        }
      },
      "id": "check-approval",
      "name": "\u2705 \u00bfAprobado?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        2400,
        464
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $env.SUPABASE_URL }}/rest/v1/content_sessions",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "apikey",
              "value": "={{ $env.SUPABASE_ANON_KEY }}"
            },
            {
              "name": "Authorization",
              "value": "=Bearer {{ $env.SUPABASE_ANON_KEY }}"
            },
            {
              "name": "Accept",
              "value": "application/vnd.pgrst.object+json"
            }
          ]
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "approval_number",
              "value": "=eq.{{ $json.body.whatsappInboundMessage.from }}"
            },
            {
              "name": "status",
              "value": "eq.pending"
            },
            {
              "name": "order",
              "value": "created_at.desc"
            },
            {
              "name": "limit",
              "value": "1"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "retrieve-session",
      "name": "\ud83d\udd0d Recuperar sesi\u00f3n Supabase",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2624,
        384
      ],
      "notes": "GET pending session from content_sessions, filtered by approval_number = phone + status=pending. Uses PostgREST Accept header 'application/vnd.pgrst.object+json' so response is a SINGLE object (not array), giving downstream nodes the flat session directly. Empty result \u2192 406 \u2192 execution fails with clear error (same as the old Code node behavior). Built as HTTP Request because n8n task runner sandbox blocks every HTTP helper inside Code nodes."
    },
    {
      "parameters": {
        "jsCode": "// Normalize both single-post and carousel briefs into the sub-workflow's expected input shape.\n// Single post: $json has final_image_url (string).\n// Carousel: $json has image_urls (array of strings) and num_images.\n//\n// Output shape for Execute Workflow:\n//   { image_urls: [{ index, url }], post_id, approval_number }\n\nconst data = $input.first().json;\nconst format = data.format || 'single';\n\nlet imageUrls = [];\nif (format === 'carousel' && Array.isArray(data.image_urls)) {\n  imageUrls = data.image_urls.map((url, i) => ({ index: i + 1, url }));\n} else if (data.final_image_url) {\n  imageUrls = [{ index: 1, url: data.final_image_url }];\n} else {\n  throw new Error('Prep Re-host Input: no image URL found on approved post (neither final_image_url nor image_urls)');\n}\n\n// post_id: derive from session/timestamp. Prefer an existing session_id if present (set by Recuperar sesion Supabase).\nconst postId = data.session_id || ('propulsar_' + Date.now());\n\n// IGPUB-06: Re-extract hashtags from the stored instagram_caption (Supabase stores the original with hashtags).\n// The content-generation path runs extraction for WA preview display only; the approval path needs\n// hashtag_block derived here so it threads through Merge Rehost Output to the IG comment node.\nconst igCaption = data.instagram_caption || '';\nconst captionLines = igCaption.split('\\n');\nconst hashtagLines = captionLines.filter(l => l.trim().startsWith('#'));\nconst cleanLines = captionLines.filter(l => !l.trim().startsWith('#'));\nconst cleanCaption = cleanLines.join('\\n').trimEnd();\nconst hashtagBlock = hashtagLines.join(' ').trim();\n\nreturn [{\n  json: {\n    // Pass ALL upstream fields through so downstream nodes (Sheets Log, future Meta publish)\n    // still have access to topic, type, angle, instagram/facebook text, etc.\n    ...data,\n    // Clean caption (no hashtag lines) \u2014 used by IG Create Container\n    instagram_caption: cleanCaption,\n    // hashtag_block threaded to Merge Rehost Output \u2192 IG comment node\n    hashtag_block: hashtagBlock,\n    // Plus the sub-workflow input fields:\n    image_urls: imageUrls,\n    post_id: postId,\n    approval_number: data.approval_number\n  }\n}];",
        "mode": "runOnceForAllItems"
      },
      "id": "prep-rehost-input",
      "name": "\ud83d\udd27 Prep Re-host Input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3060,
        380
      ],
      "notes": "Normalizes single-post (final_image_url) into the sub-workflow input shape. Phase 7: carousel guard removed \u2014 carousel path now handled upstream by \ud83d\udd00 \u00bfFormato Carrusel?. Passes all other fields through so downstream nodes retain topic/type/angle/text."
    },
    {
      "parameters": {
        "source": "database",
        "workflowId": {
          "__rl": true,
          "mode": "list",
          "value": "BIaG266Q6AZpv4Sq",
          "cachedResultName": "Re-host Images to Azure Blob"
        },
        "workflowInputs": {
          "mappingMode": "passthrough"
        },
        "options": {
          "waitForSubWorkflow": true
        }
      },
      "id": "execute-rehost-subflow",
      "name": "\ud83d\udd01 Re-host Images",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1.2,
      "position": [
        3280,
        380
      ],
      "notes": "Calls the Re-host Images sub-workflow (n8n workflow ID BIaG266Q6AZpv4Sq). workflowInputs.mappingMode MUST be 'passthrough' because the sub-workflow trigger uses inputSource: passthrough \u2014 using 'defineBelow' with an empty value would send empty input and the sub-workflow's Explode node would throw. Output: { blob_urls, post_id }. NOTE: Execute Workflow replaces the item with the sub-workflow output, so downstream nodes no longer see topic/type/angle/etc. \u2014 that's why we need the '\ud83d\udd17 Merge Rehost Output' node next. If sub-workflow throws StopAndError, this node fails and the main execution aborts \u2014 Google Sheets Log and any Meta publish nodes will NOT run."
    },
    {
      "parameters": {
        "mode": "manual",
        "duplicateItem": false,
        "assignments": {
          "assignments": [
            {
              "id": "merge-blob-urls",
              "name": "blob_urls",
              "type": "array",
              "value": "={{ $json.blob_urls }}"
            },
            {
              "id": "merge-post-id",
              "name": "post_id",
              "type": "string",
              "value": "={{ $json.post_id }}"
            },
            {
              "id": "merge-topic",
              "name": "topic",
              "type": "string",
              "value": "={{ $('\ud83d\udd27 Prep Re-host Input').item.json.topic }}"
            },
            {
              "id": "merge-type",
              "name": "type",
              "type": "string",
              "value": "={{ $('\ud83d\udd27 Prep Re-host Input').item.json.type }}"
            },
            {
              "id": "merge-angle",
              "name": "angle",
              "type": "string",
              "value": "={{ $('\ud83d\udd27 Prep Re-host Input').item.json.angle }}"
            },
            {
              "id": "merge-platforms",
              "name": "platforms",
              "type": "array",
              "value": "={{ $('\ud83d\udd27 Prep Re-host Input').item.json.platforms }}"
            },
            {
              "id": "merge-image-model",
              "name": "image_model",
              "type": "string",
              "value": "={{ $('\ud83d\udd27 Prep Re-host Input').item.json.image_model }}"
            },
            {
              "id": "merge-instagram-caption",
              "name": "instagram_caption",
              "type": "string",
              "value": "={{ $('\ud83d\udd27 Prep Re-host Input').item.json.instagram_caption }}"
            },
            {
              "id": "merge-facebook-caption",
              "name": "facebook_caption",
              "type": "string",
              "value": "={{ $('\ud83d\udd27 Prep Re-host Input').item.json.facebook_caption }}"
            },
            {
              "id": "merge-final-image",
              "name": "final_image_url",
              "type": "string",
              "value": "={{ $('\ud83d\udd27 Prep Re-host Input').item.json.final_image_url }}"
            },
            {
              "id": "merge-format",
              "name": "format",
              "type": "string",
              "value": "={{ $('\ud83d\udd27 Prep Re-host Input').item.json.format }}"
            },
            {
              "id": "merge-approval-number",
              "name": "approval_number",
              "type": "string",
              "value": "={{ $('\ud83d\udd27 Prep Re-host Input').item.json.approval_number }}"
            },
            {
              "id": "merge-hashtag-block",
              "name": "hashtag_block",
              "type": "string",
              "value": "={{ $('\ud83d\udd27 Prep Re-host Input').item.json.hashtag_block || '' }}"
            }
          ]
        },
        "options": {}
      },
      "id": "merge-rehost-output",
      "name": "\ud83d\udd17 Merge Rehost Output",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        3500,
        380
      ],
      "notes": "Re-attaches original session metadata (from \ud83d\udd27 Prep Re-host Input) to the sub-workflow output (blob_urls, post_id). Without this, Execute Workflow replaces the item with only { blob_urls, post_id }, and Google Sheets Log would write blank columns. Uses a Set node (no jsCode) to avoid any env concern. If the Wizard brief ever adds new session fields, extend this node's assignments array accordingly."
    },
    {
      "parameters": {
        "jsCode": "console.log('Rechazado:', $json.body?.whatsappInboundMessage?.from, new Date().toISOString());\nreturn [{ json: { status:'rejected', ts: new Date().toISOString() } }];"
      },
      "id": "log-rejected",
      "name": "\u274c Loguear rechazo",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2624,
        560
      ]
    },
    {
      "parameters": {
        "jsCode": "const carousel = $('\ud83d\udd27 Parsear prompts carrusel').first().json;\nconst loopItems = $input.all();\n\n// IMG-02: Normalize and validate all image URLs\nconst imageUrls = loopItems.map(item => {\n  const url = item.json.data?.[0]?.url || null;\n  if (url && typeof url === 'string' && url.startsWith('https://')) {\n    return url; // Keep full URL with auth params (exp, sig)\n  }\n  return null;\n}).filter(Boolean);\n\nif (imageUrls.length !== carousel.num_images) {\n  throw new Error('Se generaron ' + imageUrls.length + ' imagenes, se esperaban ' + carousel.num_images + '. Faltan slides.');\n}\n\nreturn [{\n  json: {\n    instagram:       carousel.instagram,\n    facebook:        carousel.facebook,\n    topic:           carousel.topic,\n    type:            carousel.type,\n    angle:           carousel.angle || null,\n    platforms:       carousel.platforms,\n    image_model:     'ideogram',\n    approval_number: carousel.approval_number,\n    timestamp:       carousel.timestamp,\n    publish_at:      carousel.publish_at || 'now',\n    format:          'carousel',\n    num_images:      carousel.num_images,\n    image_urls:      imageUrls,\n    final_image_url: imageUrls[0] || null,\n  }\n}];"
      },
      "id": "collect-carousel-urls",
      "name": "\ud83d\uddc2\ufe0f Collect Image URLs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1744,
        528
      ]
    },
    {
      "parameters": {
        "jsCode": "const data = $input.first().json;\nconst urls = data.image_urls;\n\nreturn urls.map((url, idx) => ({\n  json: {\n    current_image_url: url,\n    slide_index:       idx + 1,\n    num_images:        data.num_images,\n    approval_number:   data.approval_number,\n    _carousel_data:    data,\n  }\n}));"
      },
      "id": "split-wa-urls",
      "name": "\ud83d\udce8 Split URLs WA",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1968,
        528
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.ycloud.com/v2/whatsapp/messages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-API-Key",
              "value": "={{ $env.YCLOUD_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ \"from\": $env.YCLOUD_WHATSAPP_NUMBER, \"to\": $json.approval_number, \"type\": \"image\", \"image\": { \"link\": $json.current_image_url, \"caption\": \"Slide \" + $json.slide_index + \" de \" + $json.num_images } }) }}",
        "options": {}
      },
      "id": "send-wa-image",
      "name": "\ud83d\udce4 Enviar imagen WA",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2400,
        624
      ],
      "notes": "YCloud sendDirectly \u2014 sends one carousel image per loop iteration"
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $env.GOOGLE_SHEETS_ID }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Log"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Fecha": "={{ new Date().toISOString() }}",
            "Tema": "={{ $('\ud83d\udd17 Merge Rehost Output').item.json.topic }}",
            "Tipo": "={{ $('\ud83d\udd17 Merge Rehost Output').item.json.type }}",
            "Angulo": "={{ $('\ud83d\udd17 Merge Rehost Output').item.json.angle || '' }}",
            "Plataformas": "={{ Array.isArray($('\ud83d\udd17 Merge Rehost Output').item.json.platforms) ? $('\ud83d\udd17 Merge Rehost Output').item.json.platforms.join(', ') : '' }}",
            "Modelo_Imagen": "={{ $('\ud83d\udd17 Merge Rehost Output').item.json.image_model }}",
            "Imagen_URL": "={{ ($('\ud83d\udd17 Merge Rehost Output').item.json.blob_urls && $('\ud83d\udd17 Merge Rehost Output').item.json.blob_urls[0] && $('\ud83d\udd17 Merge Rehost Output').item.json.blob_urls[0].url) || $('\ud83d\udd17 Merge Rehost Output').item.json.final_image_url || '' }}",
            "Estado": "Publicado",
            "IG_URL": "={{ $('\ud83d\udd17 IG: Get Permalink').item.json.permalink }}",
            "FB_URL": "={{ 'https://www.facebook.com/' + $('\ud83c\udf10 FB: Publish Photo').item.json.post_id }}",
            "Publicado_En": "={{ new Date().toISOString() }}",
            "Publish_Status": "success",
            "Error_Msg": ""
          },
          "schema": [
            {
              "id": "Fecha",
              "displayName": "Fecha",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Tema",
              "displayName": "Tema",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Tipo",
              "displayName": "Tipo",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Angulo",
              "displayName": "Angulo",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Plataformas",
              "displayName": "Plataformas",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Modelo_Imagen",
              "displayName": "Modelo_Imagen",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Imagen_URL",
              "displayName": "Imagen_URL",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Estado",
              "displayName": "Estado",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "IG_URL",
              "displayName": "IG_URL",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "FB_URL",
              "displayName": "FB_URL",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Publicado_En",
              "displayName": "Publicado_En",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Publish_Status",
              "displayName": "Publish_Status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Error_Msg",
              "displayName": "Error_Msg",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "operation": "append",
        "resource": "sheet"
      },
      "id": "log-sheets",
      "name": "\ud83d\udcca Google Sheets Log",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.4,
      "position": [
        5480,
        480
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{ $json.body.format }}",
              "value2": "carousel"
            }
          ]
        }
      },
      "id": "carousel-check",
      "name": "\ud83d\udd00 \u00bfCarrusel?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        432,
        528
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.ycloud.com/v2/whatsapp/messages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-API-Key",
              "value": "={{ $env.YCLOUD_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ \"from\": $env.YCLOUD_WHATSAPP_NUMBER, \"to\": $json.approval_number, \"type\": \"image\", \"image\": { \"link\": $json.final_image_url, \"caption\": \"Preview \u2014 \" + $json.topic } }) }}",
        "options": {}
      },
      "id": "send-single-image",
      "name": "\ud83d\udce4 Enviar preview imagen",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1200,
        464
      ],
      "notes": "Single-post: sends image preview before text message"
    },
    {
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{ $json.image_model }}",
              "value2": "ideogram"
            }
          ]
        }
      },
      "id": "router-if-ideogram",
      "name": "\ud83c\udfa8 \u00bfIdeogram?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        1088,
        448
      ]
    },
    {
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{ $json.image_model }}",
              "value2": "nanoBanana"
            }
          ]
        }
      },
      "id": "router-if-nano",
      "name": "\ud83c\udfa8 \u00bfNanoBanana?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        1280,
        544
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://graph.facebook.com/v22.0/{{ $env.INSTAGRAM_ACCOUNT_ID }}/media",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ image_url: $json.blob_urls[0].url, caption: $json.instagram_caption, access_token: $env.META_PAGE_TOKEN }) }}",
        "options": {}
      },
      "id": "ig-create-container",
      "name": "\ud83d\udce4 IG: Create Container",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3940,
        480
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 3000,
      "onError": "continueErrorOutput",
      "notes": "Creates IG media container. Idempotent \u2014 retry is safe. Reads blob_urls[0].url (permanent Azure URL from Phase 4) and instagram.caption (from GPT-4o). Output: { id: <container_id> }. Common errors: 9004/2207052 (image_url unreachable), 190 (token expired)."
    },
    {
      "parameters": {
        "amount": 30,
        "unit": "seconds"
      },
      "id": "wait-container-ready",
      "name": "\u23f3 Wait 30s (container ready)",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1,
      "position": [
        4160,
        480
      ],
      "notes": "Fixed 30s wait per IGPUB-03. Container typically reaches FINISHED in 2-5s for single photos, but 30s gives a safe margin. Phase 5 Success Criterion 5 requires this gap be visible in execution trace. Polling (HARD-03) deferred to v2."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://graph.facebook.com/v22.0/{{ $env.INSTAGRAM_ACCOUNT_ID }}/media_publish",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ creation_id: $json.id, access_token: $env.META_PAGE_TOKEN }) }}",
        "options": {}
      },
      "id": "ig-media-publish",
      "name": "\ud83d\ude80 IG: media_publish",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4380,
        480
      ],
      "retryOnFail": false,
      "maxTries": 1,
      "waitBetweenTries": 0,
      "onError": "continueErrorOutput",
      "notes": "CRITICAL: Retry DISABLED (IGPUB-04, ERR-02). media_publish is NOT idempotent \u2014 a retry after a timeout where Meta already processed the request creates a duplicate live IG post. If this node fails, execution aborts \u2014 Felix manually verifies IG profile and n8n logs. Error 9007/2207027 means container not ready (increase Wait)."
    },
    {
      "parameters": {
        "method": "GET",
        "url": "=https://graph.facebook.com/v22.0/{{ $json.id }}",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "fields",
              "value": "permalink"
            },
            {
              "name": "access_token",
              "value": "={{ $env.META_PAGE_TOKEN }}"
            }
          ]
        },
        "options": {}
      },
      "id": "ig-get-permalink",
      "name": "\ud83d\udd17 IG: Get Permalink",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4820,
        480
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 1000,
      "onError": "continueErrorOutput",
      "notes": "GETs the published IG post's permalink (IGPUB-05). Input: { id: <media_id> } from media_publish. Output: { id, permalink }. Idempotent GET \u2014 retry is safe."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://graph.facebook.com/v22.0/{{ $('\ud83d\ude80 IG: media_publish').item.json.id }}/comments",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ message: $('\ud83d\udd17 Merge Rehost Output').item.json.hashtag_block, access_token: $env.META_PAGE_TOKEN }) }}",
        "options": {}
      },
      "id": "ig-post-hashtag-comment",
      "name": "\ud83d\udcac IG: Post Hashtag Comment",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4600,
        480
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "onError": "continueErrorOutput",
      "notes": "IGPUB-06: Posts hashtags as first comment on the published IG post. Non-blocking \u2014 if comment fails, post is already live. onError=continueErrorOutput so publish chain continues. URL uses explicit cross-ref to media_publish to avoid Set v3.0 fan-out data-drop. message = hashtag_block from Merge Rehost Output."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.ycloud.com/v2/whatsapp/messages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-API-Key",
              "value": "={{ $env.YCLOUD_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ from: $env.YCLOUD_WHATSAPP_NUMBER, to: $('\ud83d\udd17 Merge Rehost Output').item.json.approval_number, type: 'text', text: { body: '\u2713 Publicado en Instagram y Facebook\\n\\nTema: ' + $('\ud83d\udd17 Merge Rehost Output').item.json.topic + '\\nInstagram: ' + $('\ud83d\udd17 IG: Get Permalink').item.json.permalink + '\\nFacebook: https://www.facebook.com/' + $('\ud83c\udf10 FB: Publish Photo').item.json.post_id + '\\nHora: ' + new Date().toISOString() } }) }}",
        "options": {}
      },
      "id": "notify-wa-success",
      "name": "\u2705 Notify WhatsApp Success",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5260,
        480
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "onError": "stopWorkflow",
      "notes": "Sends WhatsApp text with 'Publicado' + IG permalink + timestamp (NOTIF-01). Cross-refs Merge Rehost Output for topic + approval_number because $json at this point is { id, permalink } from the permalink GET. Uses JSON.stringify({...}) wrapper per project pattern from commit effab36 to avoid quote-escaping issues."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://graph.facebook.com/v22.0/{{ $env.FACEBOOK_PAGE_ID }}/photos",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ url: $('\ud83d\udd17 Merge Rehost Output').item.json.blob_urls[0].url, message: $('\ud83d\udd17 Merge Rehost Output').item.json.facebook_caption || $('\ud83d\udd17 Merge Rehost Output').item.json.instagram_caption || '', access_token: $env.META_PAGE_TOKEN }) }}",
        "options": {}
      },
      "id": "fb-publish-photo",
      "name": "\ud83c\udf10 FB: Publish Photo",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "retryOnFail": false,
      "maxTries": 1,
      "waitBetweenTries": 0,
      "onError": "continueErrorOutput",
      "position": [
        5040,
        480
      ],
      "notes": "CRITICAL: Retry DISABLED (FBPUB-01, ERR-02 equivalent). POST /{PAGE_ID}/photos is NOT idempotent \u2014 retry after timeout where Meta already processed creates a duplicate live FB post. Input: blob_urls[0].url from Merge Rehost Output. Output: { id: <photo_id>, post_id: '<page_id>_<photo_id>' }. Post_id used to construct FB_URL as 'https://www.facebook.com/' + post_id."
    },
    {
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{ $json.format }}",
              "value2": "carousel"
            }
          ]
        }
      },
      "id": "format-carousel-branch",
      "name": "\ud83d\udd00 \u00bfFormato Carrusel?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        3720,
        380
      ],
      "notes": "Phase 7: Routes carousel briefs (format=carousel) to the carousel publish chain (TRUE output 0 \u2014 connected by Plan 02). Single-post briefs (format!=carousel) continue to the existing IG publish chain (FALSE output 1). Uses IF typeVersion 1 \u2014 v2/Switch v3 broken in n8n 2.14.2."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.SUPABASE_URL }}/rest/v1/content_sessions",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "apikey",
              "value": "={{ $env.SUPABASE_ANON_KEY }}"
            },
            {
              "name": "Authorization",
              "value": "=Bearer {{ $env.SUPABASE_ANON_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Prefer",
              "value": "return=representation"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ session_id: 'propulsar_' + Date.now(), approval_number: $json.approval_number, topic: $json.topic, type: $json.type, angle: $json.angle || null, platforms: $json.platforms || [], image_model: $json.image_model || 'ideogram', format: 'carousel', image_urls: $json.image_urls, instagram_caption: $json.instagram_caption || ($json.instagram && $json.instagram.caption) || null, facebook_caption: $json.facebook_caption || ($json.facebook && $json.facebook.caption) || null, status: 'pending', publish_at: $json.publish_at || 'now' }) }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "save-session-carousel",
      "name": "\ud83d\udcbe Guardar sesi\u00f3n Supabase (Carousel)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 1000,
      "position": [
        1968,
        700
      ],
      "notes": "Inserts a carousel session into content_sessions BEFORE the WA preview is sent. Mirrors \ud83d\udcbe Guardar sesi\u00f3n Supabase (single-post). Uses format='carousel' and image_urls (JSONB array). retryOnFail=true \u2014 Supabase INSERT is idempotent by session_id (timestamp-based), safe to retry. Response = array of the inserted row."
    },
    {
      "parameters": {
        "mode": "manual",
        "duplicateItem": false,
        "assignments": {
          "assignments": [
            {
              "id": "rc-a1",
              "name": "topic",
              "type": "string",
              "value": "={{ $('\ud83d\uddc2\ufe0f Collect Image URLs').first().json.topic }}"
            },
            {
              "id": "rc-a2",
              "name": "type",
              "type": "string",
              "value": "={{ $('\ud83d\uddc2\ufe0f Collect Image URLs').first().json.type }}"
            },
            {
              "id": "rc-a3",
              "name": "angle",
              "type": "string",
              "value": "={{ $('\ud83d\uddc2\ufe0f Collect Image URLs').first().json.angle }}"
            },
            {
              "id": "rc-a4",
              "name": "platforms",
              "type": "array",
              "value": "={{ $('\ud83d\uddc2\ufe0f Collect Image URLs').first().json.platforms }}"
            },
            {
              "id": "rc-a5",
              "name": "image_model",
              "type": "string",
              "value": "={{ $('\ud83d\uddc2\ufe0f Collect Image URLs').first().json.image_model }}"
            },
            {
              "id": "rc-a6",
              "name": "format",
              "type": "string",
              "value": "={{ $('\ud83d\uddc2\ufe0f Collect Image URLs').first().json.format }}"
            },
            {
              "id": "rc-a7",
              "name": "num_images",
              "type": "number",
              "value": "={{ $('\ud83d\uddc2\ufe0f Collect Image URLs').first().json.num_images }}"
            },
            {
              "id": "rc-a8",
              "name": "image_urls",
              "type": "array",
              "value": "={{ $('\ud83d\uddc2\ufe0f Collect Image URLs').first().json.image_urls }}"
            },
            {
              "id": "rc-a9",
              "name": "instagram_caption",
              "type": "string",
              "value": "={{ $('\ud83d\uddc2\ufe0f Collect Image URLs').first().json.instagram_caption || ($('\ud83d\uddc2\ufe0f Collect Image URLs').first().json.instagram && $('\ud83d\uddc2\ufe0f Collect Image URLs').first().json.instagram.caption) || '' }}"
            },
            {
              "id": "rc-a10",
              "name": "facebook_caption",
              "type": "string",
              "value": "={{ $('\ud83d\uddc2\ufe0f Collect Image URLs').first().json.facebook_caption || ($('\ud83d\uddc2\ufe0f Collect Image URLs').first().json.facebook && $('\ud83d\uddc2\ufe0f Collect Image URLs').first().json.facebook.caption) || '' }}"
            },
            {
              "id": "rc-a11",
              "name": "approval_number",
              "type": "string",
              "value": "={{ $('\ud83d\uddc2\ufe0f Collect Image URLs').first().json.approval_number }}"
            },
            {
              "id": "rc-a12",
              "name": "session_id",
              "type": "string",
              "value": "={{ $('\ud83d\udcbe Guardar sesi\u00f3n Supabase (Carousel)').first().json.session_id }}"
            }
          ]
        },
        "includeOtherFields": false,
        "options": {}
      },
      "id": "reattach-carousel-data",
      "name": "\ud83d\udd17 Re-attach carousel data",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2192,
        700
      ],
      "notes": "Supabase HTTP Request replaced the item with the inserted row. Re-attaches the pre-insert carousel data via cross-ref to Collect Image URLs, plus session_id from the Supabase insert response. Mirrors \ud83d\udd17 Re-attach session data on the single-post path. Downstream WA nodes (Split URLs WA, Preparar mensaje WA) need the original carousel data shape."
    },
    {
      "parameters": {
        "jsCode": "const data = $('\ud83d\udd17 Merge Rehost Output').first().json;\nconst blobUrls = data.blob_urls || [];\nif (blobUrls.length === 0) {\n  throw new Error('IG Carousel Explode: no blob_urls found in Merge Rehost Output');\n}\nif (blobUrls.length > 10) {\n  throw new Error('IG Carousel Explode: carousel exceeds 10-slide Meta limit (' + blobUrls.length + ' slides)');\n}\nreturn blobUrls.map((entry, i) => ({\n  json: {\n    slide_index: entry.index || (i + 1),\n    blob_url: entry.url,\n    num_images: blobUrls.length,\n  }\n}));",
        "mode": "runOnceForAllItems"
      },
      "id": "ig-carousel-explode",
      "name": "\ud83c\udfa0 IG: Explode Carousel Slides",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3940,
        200
      ],
      "notes": "Explodes blob_urls array from Merge Rehost Output into one item per slide. Validates: not empty, not > 10 slides (Meta limit). Connected from TRUE output of \u00bfFormato Carrusel?."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://graph.facebook.com/v22.0/{{ $env.INSTAGRAM_ACCOUNT_ID }}/media",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ image_url: $json.blob_url, is_carousel_item: true, access_token: $env.META_PAGE_TOKEN }) }}",
        "options": {}
      },
      "id": "ig-create-child-container",
      "name": "\ud83d\uddbc\ufe0f IG: Create Child Container",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4160,
        200
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 3000,
      "onError": "continueErrorOutput",
      "notes": "Creates one IG child container per carousel slide. is_carousel_item=true required by Meta. Idempotent \u2014 retry is safe. Runs once per slide (fan-out from Explode). Output: { id: <child_container_id> }."
    },
    {
      "parameters": {
        "jsCode": "const items = $input.all();\nconst session = $('\ud83d\udd17 Merge Rehost Output').first().json;\nconst childIds = items.map(it => it.json.id).filter(Boolean);\nif (childIds.length !== session.blob_urls.length) {\n  throw new Error('IG Carousel: expected ' + session.blob_urls.length + ' child containers, got ' + childIds.length);\n}\nreturn [{\n  json: {\n    topic: session.topic,\n    type: session.type,\n    angle: session.angle || null,\n    platforms: session.platforms,\n    image_model: session.image_model,\n    instagram_caption: session.instagram_caption,\n    facebook_caption: session.facebook_caption,\n    blob_urls: session.blob_urls,\n    approval_number: session.approval_number,\n    format: session.format,\n    num_images: session.blob_urls.length,\n    child_ids: childIds,\n    children_csv: childIds.join(','),\n  }\n}];",
        "mode": "runOnceForAllItems"
      },
      "id": "ig-collect-child-ids",
      "name": "\ud83d\uddc2\ufe0f IG: Collect Child IDs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4380,
        200
      ],
      "notes": "Aggregates all child container IDs after fan-out. Validates count matches expected. Builds children_csv for parent container. Cross-refs Merge Rehost Output for session metadata. Output: single item with full session + child_ids + children_csv."
    },
    {
      "parameters": {
        "amount": 45,
        "unit": "seconds"
      },
      "id": "ig-wait-carousel",
      "name": "\u23f3 IG: Wait 30s Carousel",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1,
      "position": [
        4600,
        200
      ],
      "notes": "30s wait between child container creation and parent carousel container creation. Meta requires all child containers to reach FINISHED state before parent can be created. Same pattern as Wait 30s (container ready) on single-post path."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://graph.facebook.com/v22.0/{{ $env.INSTAGRAM_ACCOUNT_ID }}/media",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ media_type: 'CAROUSEL', children: $json.children_csv, caption: $json.instagram_caption, access_token: $env.META_PAGE_TOKEN }) }}",
        "options": {}
      },
      "id": "ig-create-parent-container",
      "name": "\ud83c\udfa0 IG: Create Parent Container",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4820,
        200
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 3000,
      "onError": "continueErrorOutput",
      "notes": "Creates the IG carousel parent container using all child IDs (comma-separated). media_type=CAROUSEL required. Idempotent \u2014 retry is safe. Output: { id: <parent_container_id> }."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://graph.facebook.com/v22.0/{{ $env.INSTAGRAM_ACCOUNT_ID }}/media_publish",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ creation_id: $json.id, access_token: $env.META_PAGE_TOKEN }) }}",
        "options": {}
      },
      "id": "ig-carousel-media-publish",
      "name": "\ud83d\ude80 IG: Carousel media_publish",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5040,
        200
      ],
      "retryOnFail": false,
      "maxTries": 1,
      "waitBetweenTries": 0,
      "onError": "continueErrorOutput",
      "notes": "CRITICAL: Retry DISABLED. media_publish is NOT idempotent \u2014 retry after timeout where Meta already processed creates a duplicate live carousel post. creation_id = parent container id from previous node. Output: { id: <published_media_id> }."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://graph.facebook.com/v22.0/{{ $('\ud83d\ude80 IG: Carousel media_publish').item.json.id }}/comments",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ message: $('\ud83d\udd17 Merge Rehost Output').item.json.hashtag_block, access_token: $env.META_PAGE_TOKEN }) }}",
        "options": {}
      },
      "id": "ig-post-carousel-hashtag-comment",
      "name": "\ud83d\udcac IG: Post Carousel Hashtag Comment",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5260,
        200
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "onError": "continueErrorOutput",
      "notes": "IGPUB-06 (carousel): Posts hashtags as first comment on the published IG carousel. Non-blocking \u2014 if comment fails, post is already live. onError=continueErrorOutput so publish chain continues. URL uses explicit cross-ref to ig-carousel-media-publish to avoid fan-out data-drop."
    },
    {
      "parameters": {
        "method": "GET",
        "url": "=https://graph.facebook.com/v22.0/{{ $('\ud83d\ude80 IG: Carousel media_publish').item.json.id }}",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "fields",
              "value": "permalink"
            },
            {
              "name": "access_token",
              "value": "={{ $env.META_PAGE_TOKEN }}"
            }
          ]
        },
        "options": {}
      },
      "id": "ig-get-carousel-permalink",
      "name": "\ud83d\udd17 IG: Get Carousel Permalink",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5480,
        200
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 1000,
      "onError": "continueErrorOutput",
      "notes": "GETs the published IG carousel post permalink. Idempotent GET \u2014 retry is safe. Output: { id, permalink }. Downstream FB chain uses cross-refs to Collect Child IDs for session data."
    },
    {
      "parameters": {
        "jsCode": "const session = $('\ud83d\udd17 Merge Rehost Output').first().json;\nconst blobUrls = session.blob_urls || [];\nreturn blobUrls.map((entry, i) => ({\n  json: {\n    slide_index: entry.index || (i + 1),\n    blob_url: entry.url,\n  }\n}));",
        "mode": "runOnceForAllItems"
      },
      "id": "fb-carousel-explode",
      "name": "\ud83d\uddbc\ufe0f FB: Explode Carousel Slides",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5700,
        200
      ],
      "notes": "Explodes blob_urls from Merge Rehost Output into per-slide items for FB unpublished photo upload. Mirrors IG: Explode Carousel Slides but simpler (no count validation \u2014 already done in IG chain)."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://graph.facebook.com/v22.0/{{ $env.FACEBOOK_PAGE_ID }}/photos",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ url: $json.blob_url, published: false, access_token: $env.META_PAGE_TOKEN }) }}",
        "options": {}
      },
      "id": "fb-upload-photo-unpublished",
      "name": "\ud83d\udce4 FB: Upload Photo Unpublished",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5920,
        200
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 3000,
      "onError": "continueErrorOutput",
      "notes": "CRITICAL: Retry DISABLED. POST /{PAGE_ID}/photos with published=false creates an unpublished photo that will be included in the multi-photo feed post. Not safe to retry after timeout \u2014 could create orphaned unpublished photos. Output: { id: <photo_id> }."
    },
    {
      "parameters": {
        "jsCode": "const items = $input.all();\nconst session = $('\ud83d\udd17 Merge Rehost Output').first().json;\nconst photoIds = items.map(it => it.json.id).filter(Boolean);\nif (photoIds.length !== session.blob_urls.length) {\n  throw new Error('FB Carousel: expected ' + session.blob_urls.length + ' unpublished photos, got ' + photoIds.length);\n}\nreturn [{\n  json: {\n    photo_ids: photoIds,\n    facebook_caption: session.facebook_caption || session.instagram_caption || '',\n    instagram_permalink: $('\ud83d\udd17 IG: Get Carousel Permalink').first().json.permalink,\n    approval_number: session.approval_number,\n    topic: session.topic,\n    type: session.type,\n    angle: session.angle || null,\n    image_model: session.image_model,\n    num_images: session.blob_urls.length,\n    format: 'carousel',\n    blob_urls: session.blob_urls,\n    instagram_caption: session.instagram_caption,\n  }\n}];",
        "mode": "runOnceForAllItems"
      },
      "id": "fb-collect-photo-ids",
      "name": "\ud83d\uddc2\ufe0f FB: Collect Photo IDs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        6140,
        200
      ],
      "notes": "Aggregates all FB unpublished photo IDs after fan-out. Validates count. Cross-refs Merge Rehost Output for session data and IG: Get Carousel Permalink for instagram_permalink. Output: single item with full context needed for Publish Feed + WA notification + Sheets log."
    },
    {
      "parameters": {
        "jsCode": "const data = $input.first().json;\nconst photoIds = data.photo_ids;\nif (!Array.isArray(photoIds) || photoIds.length === 0) {\n  throw new Error('FB Build attached_media: no photo_ids found');\n}\nconst attachedMedia = photoIds.map(id => ({ media_fbid: id }));\nreturn [{\n  json: {\n    ...data,\n    attached_media: attachedMedia,\n  }\n}];",
        "mode": "runOnceForAllItems"
      },
      "id": "fb-build-attached-media",
      "name": "\ud83d\udd27 FB: Build attached_media",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        6360,
        200
      ],
      "notes": "Builds the attached_media array required by /feed multi-photo POST. Format: [{ media_fbid: photo_id }, ...]. Passes through all session data for downstream nodes."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://graph.facebook.com/v22.0/{{ $env.FACEBOOK_PAGE_ID }}/feed",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ message: $json.facebook_caption, attached_media: $json.attached_media, access_token: $env.META_PAGE_TOKEN }) }}",
        "options": {}
      },
      "id": "fb-publish-carousel-feed",
      "name": "\ud83c\udf10 FB: Publish Carousel Feed",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        6580,
        200
      ],
      "retryOnFail": false,
      "maxTries": 1,
      "waitBetweenTries": 0,
      "onError": "continueErrorOutput",
      "notes": "CRITICAL: Retry DISABLED. POST /{PAGE_ID}/feed with attached_media is NOT idempotent \u2014 retry creates a duplicate live FB carousel post. Output: { id: <post_id> }. If this fails with 'Invalid parameter: attached_media', fallback is to switch specifyBody to 'string' and stringify the full body in Build attached_media."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.ycloud.com/v2/whatsapp/messages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-API-Key",
              "value": "={{ $env.YCLOUD_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ from: $env.YCLOUD_WHATSAPP_NUMBER, to: $('\ud83d\uddc2\ufe0f FB: Collect Photo IDs').first().json.approval_number, type: 'text', text: { body: '\u2705 Carrusel publicado (' + $('\ud83d\uddc2\ufe0f FB: Collect Photo IDs').first().json.num_images + ' slides)\\n\\n\ud83d\udcf8 Instagram: ' + $('\ud83d\uddc2\ufe0f FB: Collect Photo IDs').first().json.instagram_permalink + '\\n\ud83d\udcd8 Facebook: https://www.facebook.com/' + $json.id + '\\n\\n\ud83d\udcdd Tema: ' + $('\ud83d\uddc2\ufe0f FB: Collect Photo IDs').first().json.topic } }) }}",
        "options": {}
      },
      "id": "notify-wa-carousel",
      "name": "\u2705 Notify WhatsApp Carousel",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        6800,
        200
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "onError": "stopWorkflow",
      "notes": "Sends carousel publish confirmation via WhatsApp. Cross-refs FB: Collect Photo IDs for session data (approval_number, num_images, instagram_permalink, topic). $json.id is the FB feed post_id from Publish Carousel Feed response. Retry is safe (WA message dedup by YCloud)."
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $env.GOOGLE_SHEETS_ID }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Log"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Fecha": "={{ new Date().toISOString() }}",
            "Tema": "={{ $('\ud83d\uddc2\ufe0f FB: Collect Photo IDs').first().json.topic }}",
            "Tipo": "={{ $('\ud83d\uddc2\ufe0f FB: Collect Photo IDs').first().json.type }}",
            "Angulo": "={{ $('\ud83d\uddc2\ufe0f FB: Collect Photo IDs').first().json.angle || '' }}",
            "Plataformas": "={{ Array.isArray($('\ud83d\uddc2\ufe0f FB: Collect Photo IDs').first().json.blob_urls) ? 'instagram, facebook' : '' }}",
            "Modelo_Imagen": "={{ $('\ud83d\uddc2\ufe0f FB: Collect Photo IDs').first().json.image_model }}",
            "Imagen_URL": "={{ ($('\ud83d\uddc2\ufe0f FB: Collect Photo IDs').first().json.blob_urls && $('\ud83d\uddc2\ufe0f FB: Collect Photo IDs').first().json.blob_urls[0] && $('\ud83d\uddc2\ufe0f FB: Collect Photo IDs').first().json.blob_urls[0].url) || '' }}",
            "Estado": "Publicado",
            "IG_URL": "={{ $('\ud83d\uddc2\ufe0f FB: Collect Photo IDs').first().json.instagram_permalink }}",
            "FB_URL": "={{ 'https://www.facebook.com/' + $('\ud83c\udf10 FB: Publish Carousel Feed').first().json.id }}",
            "Publicado_En": "={{ new Date().toISOString() }}",
            "Publish_Status": "success",
            "Error_Msg": ""
          },
          "schema": [
            {
              "id": "Fecha",
              "displayName": "Fecha",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Tema",
              "displayName": "Tema",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Tipo",
              "displayName": "Tipo",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Angulo",
              "displayName": "Angulo",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Plataformas",
              "displayName": "Plataformas",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Modelo_Imagen",
              "displayName": "Modelo_Imagen",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Imagen_URL",
              "displayName": "Imagen_URL",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Estado",
              "displayName": "Estado",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "IG_URL",
              "displayName": "IG_URL",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "FB_URL",
              "displayName": "FB_URL",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Publicado_En",
              "displayName": "Publicado_En",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Publish_Status",
              "displayName": "Publish_Status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Error_Msg",
              "displayName": "Error_Msg",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "operation": "append",
        "resource": "sheet"
      },
      "id": "log-sheets-carousel",
      "name": "\ud83d\udcca Google Sheets Log (Carousel)",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.4,
      "position": [
        7020,
        200
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "notes": "Appends a carousel publish log row to the same Log sheet as the single-post path. Uses same credentials (XjKteoOTobs1qR55) and schema. Cross-refs FB: Collect Photo IDs for session data, FB: Publish Carousel Feed for post_id. Same column schema as single-post log."
    },
    {
      "parameters": {
        "jsCode": "const data = $input.first().json;\nconst pub = data.publish_at;\n\nlet scheduled = false;\nlet wait_seconds = 0;\n\nif (pub && pub !== 'now') {\n  const diffMs = new Date(pub).getTime() - Date.now();\n  if (diffMs > 65000 && diffMs <= 86400000) {\n    scheduled = true;\n    wait_seconds = Math.round(diffMs / 1000);\n  }\n  // Past time or > 24h: route to immediate (scheduled stays false)\n}\n\nreturn [{ json: { ...data, scheduled: String(scheduled), wait_seconds } }];",
        "mode": "runOnceForAllItems"
      },
      "id": "compute-wait-seconds",
      "name": "\ud83d\udd50 Compute wait_seconds",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2780,
        380
      ],
      "notes": "SCHED-03: Computes wait_seconds from publish_at. Routes to immediate if publish_at is now, past, <65s (n8n DB threshold), or >24h. 65s floor: n8n does not persist Wait < 65s to DB \u2014 container restart would lose execution."
    },
    {
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{ $json.scheduled }}",
              "value2": "true"
            }
          ]
        }
      },
      "id": "check-scheduled",
      "name": "\u23f0 \u00bfProgramado?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        2840,
        380
      ],
      "notes": "SCHED-01: Routes scheduled=true to Wait path (output 0/TRUE), scheduled=false to immediate path (output 1/FALSE). Uses IF typeVersion 1 \u2014 v2/Switch v3 broken in n8n 2.14.2."
    },
    {
      "parameters": {
        "amount": "={{ $json.wait_seconds }}",
        "unit": "seconds"
      },
      "id": "wait-scheduled-publish",
      "name": "\u23f3 Wait \u2014 Scheduled Publish",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1,
      "position": [
        2900,
        280
      ],
      "notes": "SCHED-02: Pauses execution for wait_seconds (computed by \ud83d\udd50 Compute wait_seconds). Uses 'After Time Interval' mode (amount + unit), NOT 'At Specified Time' (unreliable per n8n #14723). wait_seconds is a Number, not string."
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Preserve all fields including 'error' (Set node drops 'error' key)\nconst item = { ...$input.item.json };\nitem._platform = 'Instagram';\nreturn { json: item };"
      },
      "id": "tag-ig-error",
      "name": "\ud83c\udff7\ufe0f Tag IG Error",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4500,
        900
      ],
      "notes": "Receives error outputs from IG Meta nodes. Adds _platform=Instagram for Parse Meta Error."
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Preserve all fields including 'error' (Set node drops 'error' key)\nconst item = { ...$input.item.json };\nitem._platform = 'Facebook';\nreturn { json: item };"
      },
      "id": "tag-fb-error",
      "name": "\ud83c\udff7\ufe0f Tag FB Error",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5500,
        900
      ],
      "notes": "Receives error outputs from FB Meta nodes. Adds _platform=Facebook for Parse Meta Error."
    },
    {
      "parameters": {
        "jsCode": "const raw = $input.first().json;\nlet err = {};\ntry {\n  const rawErr = raw.error || {};\n  if (rawErr.code && typeof rawErr.code === 'number') {\n    // Direct Meta error object with numeric code\n    err = rawErr;\n  } else if (rawErr.message) {\n    // AxiosError message format: \"400 - \\\"<json_encoded_string>\\\"\"\n    // The inner string is double-encoded JSON, need two-stage decode\n    const msg = rawErr.message;\n    // Find the outer JSON-encoded string (between first and last double-quote)\n    const qStart = msg.indexOf('\"');\n    const qEnd = msg.lastIndexOf('\"');\n    if (qStart !== -1 && qEnd > qStart) {\n      try {\n        // First decode: get the JSON string out of the quoted wrapper\n        const jsonStr = msg.slice(qStart, qEnd + 1);\n        const inner = JSON.parse(jsonStr);  // gives us the raw JSON text\n        const parsed = JSON.parse(inner);   // gives us the actual object\n        err = parsed.error || parsed;\n      } catch(e) {\n        // Fallback: try direct JSON extraction (first { to last })\n        const start = msg.indexOf('{');\n        const end = msg.lastIndexOf('}');\n        if (start !== -1 && end !== -1) {\n          try { const p = JSON.parse(msg.slice(start, end + 1)); err = p.error || p; } catch(e2) {}\n        }\n      }\n    }\n  }\n  // Fallback: raw.cause.response.body (alternate error shape)\n  if (!err.code && raw.cause && raw.cause.response && raw.cause.response.body) {\n    try { const p = JSON.parse(raw.cause.response.body); err = p.error || err; } catch(e) {}\n  }\n} catch(e) {}\n\n// Primary: Merge Rehost Output (structural guarantee: all Meta nodes downstream)\n// Fallback: Prep Re-host Input\nlet mergeData = {};\ntry { mergeData = $('\ud83d\udd17 Merge Rehost Output').first().json; } catch(e) {\n  try { mergeData = $('\ud83d\udd27 Prep Re-host Input').first().json; } catch(e2) {}\n}\n\nreturn [{\n  json: {\n    error_code: err.code || 0,\n    error_message: err.message || 'Error desconocido de Meta API',\n    error_type: err.type || '',\n    fbtrace_id: err.fbtrace_id || '',\n    platform_failed: raw._platform || 'Meta',\n    is_token_expired: (err.type === 'OAuthException' && err.code === 190),\n    approval_number: mergeData.approval_number || '',\n    topic: mergeData.topic || '',\n    blob_urls: mergeData.blob_urls || [],\n  }\n}];",
        "mode": "runOnceForAllItems"
      },
      "id": "parse-meta-error",
      "name": "\ud83d\udea8 Parse Meta Error",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5000,
        1100
      ],
      "notes": "Extracts structured error info from Meta HTTP error response. Cross-refs Merge Rehost Output for approval_number, topic, blob_urls. _platform comes from Tag IG/FB Error nodes."
    },
    {
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{ String($json.is_token_expired) }}",
              "value2": "true"
            }
          ]
        }
      },
      "id": "check-token-expired",
      "name": "\u26a0\ufe0f \u00bfToken Expirado?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        5000,
        1300
      ],
      "notes": "Routes OAuthException code 190 (expired token) to specific WA alert mentioning Susana. FALSE output -> generic WA error."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.ycloud.com/v2/whatsapp/messages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-API-Key",
              "value": "={{ $env.YCLOUD_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ from: $env.YCLOUD_WHATSAPP_NUMBER, to: $json.approval_number, type: \"text\", text: { body: \"\u26a0\ufe0f *Token Meta expirado* \u2014 verificar que Susana sigue como admin de la p\u00e1gina\\n\\nTema: \" + $json.topic + \"\\nPlataforma: \" + $json.platform_failed + \"\\nfbtrace_id: \" + $json.fbtrace_id } }) }}",
        "options": {}
      },
      "id": "wa-token-expired",
      "name": "\ud83d\udce4 WA: Token Expirado",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4600,
        1500
      ],
      "onError": "continueErrorOutput",
      "notes": "Sends WhatsApp alert for expired Meta token (OAuthException code 190). Mentions Susana as admin. onError=continueErrorOutput so WA failure does not block Sheets fail log."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.ycloud.com/v2/whatsapp/messages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-API-Key",
              "value": "={{ $env.YCLOUD_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ from: $env.YCLOUD_WHATSAPP_NUMBER, to: $json.approval_number, type: \"text\", text: { body: \"\u274c *Error publicando en \" + $json.platform_failed + \"*\\n\\nC\u00f3digo: \" + $json.error_code + \"\\nMensaje: \" + $json.error_message + \"\\nTrace ID: \" + $json.fbtrace_id + \"\\nTema: \" + $json.topic } }) }}",
        "options": {}
      },
      "id": "wa-error-publish",
      "name": "\ud83d\udce4 WA: Error Publicaci\u00f3n",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5400,
        1500
      ],
      "onError": "continueErrorOutput",
      "notes": "Sends generic WhatsApp error alert with error_code, error_message, fbtrace_id, platform. onError=continueErrorOutput so WA failure does not block Sheets fail log."
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $env.GOOGLE_SHEETS_ID }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Log"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Fecha": "={{ new Date().toISOString() }}",
            "Tema": "={{ $('\ud83d\udea8 Parse Meta Error').item.json.topic }}",
            "Tipo": "",
            "Angulo": "",
            "Plataformas": "={{ $('\ud83d\udea8 Parse Meta Error').item.json.platform_failed }}",
            "Modelo_Imagen": "",
            "Imagen_URL": "",
            "Estado": "Error",
            "IG_URL": "",
            "FB_URL": "",
            "Publicado_En": "={{ new Date().toISOString() }}",
            "Publish_Status": "failed",
            "Error_Msg": "={{ $('\ud83d\udea8 Parse Meta Error').item.json.error_message + ' [' + $('\ud83d\udea8 Parse Meta Error').item.json.fbtrace_id + ']' }}"
          },
          "schema": [
            {
              "id": "Fecha",
              "displayName": "Fecha",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Tema",
              "displayName": "Tema",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Tipo",
              "displayName": "Tipo",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Angulo",
              "displayName": "Angulo",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Plataformas",
              "displayName": "Plataformas",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Modelo_Imagen",
              "displayName": "Modelo_Imagen",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Imagen_URL",
              "displayName": "Imagen_URL",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Estado",
              "displayName": "Estado",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "IG_URL",
              "displayName": "IG_URL",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "FB_URL",
              "displayName": "FB_URL",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Publicado_En",
              "displayName": "Publicado_En",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Publish_Status",
              "displayName": "Publish_Status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Error_Msg",
              "displayName": "Error_Msg",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "operation": "append",
        "resource": "sheet"
      },
      "id": "sheets-fail-log",
      "name": "\ud83d\udcca Sheets Fail Log",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.4,
      "position": [
        5000,
        1700
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "notes": "Logs publish failures with Publish_Status=failed + Error_Msg. Same credentials and sheet as success log. Receives from both WA: Token Expirado and WA: Error Publicacion (and their error outputs)."
    },
    {
      "parameters": {
        "jsCode": "const data = $input.first().json;\nlet blobUrls = data.blob_urls || [];\nif (blobUrls.length === 0) {\n  try { blobUrls = $('\ud83d\udd17 Merge Rehost Output').first().json.blob_urls || []; } catch(e) {}\n}\nif (blobUrls.length === 0) return [{ json: { ...data, has_blobs: 'false' } }];\nconst prefix = 'https://propulsarcontent.blob.core.windows.net/posts/';\nreturn blobUrls.map(entry => ({\n  json: {\n    current_blob_name: (entry.url || entry).replace(prefix, '').split('?')[0],\n    approval_number: data.approval_number || '',\n  }\n}));",
        "mode": "runOnceForAllItems"
      },
      "id": "extract-blob-names",
      "name": "\ud83e\uddf9 Extract Blob Names",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5000,
        1900
      ],
      "notes": "Extracts blob names from blob_urls for DELETE. Receives from success Sheets logs AND Sheets Fail Log. Falls back to Merge Rehost Output cross-ref if blob_urls not in item."
    },
    {
      "parameters": {
        "method": "DELETE",
        "url": "=https://{{ $env.AZURE_STORAGE_ACCOUNT }}.blob.core.windows.net/{{ $env.AZURE_CONTAINER }}/{{ $json.current_blob_name }}?{{ $env.AZURE_SAS_PARAMS }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "x-ms-version",
              "value": "2020-10-02"
            },
            {
              "name": "x-ms-date",
              "value": "={{ new Date().toUTCString() }}"
            }
          ]
        },
        "options": {}
      },
      "id": "delete-azure-blob",
      "name": "\ud83d\uddd1\ufe0f Delete Azure Blob",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5000,
        2100
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "onError": "continueErrorOutput",
      "notes": "Deletes Azure Blob file. DELETE is idempotent (404 = already deleted, acceptable). retryOnFail=true. onError=continueErrorOutput so 404 does not block execution."
    }
  ],
  "connections": {
    "\ud83c\udfaf Webhook Trigger": {
      "main": [
        [
          {
            "node": "\u2705 Responder al Wizard",
            "type": "main",
            "index": 0
          },
          {
            "node": "\ud83d\udd00 \u00bfCarrusel?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd00 \u00bfCarrusel?": {
      "main": [
        [
          {
            "node": "\ud83c\udfa0 GPT-4o \u2014 Prompts Carrusel",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83e\udd16 GPT-4o \u2014 Texto",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83c\udfa0 GPT-4o \u2014 Prompts Carrusel": {
      "main": [
        [
          {
            "node": "\ud83d\udd27 Parsear prompts carrusel",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd27 Parsear prompts carrusel": {
      "main": [
        [
          {
            "node": "\u2702\ufe0f Extract Hashtags (Carousel)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u2702\ufe0f Extract Hashtags (Carousel)": {
      "main": [
        [
          {
            "node": "\ud83c\udfa0 Explode Slides",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83c\udfa0 Explode Slides": {
      "main": [
        [
          {
            "node": "\ud83d\udd24 Ideogram \u2014 Slide",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd24 Ideogram \u2014 Slide": {
      "main": [
        [
          {
            "node": "\ud83d\uddc2\ufe0f Collect Image URLs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\uddc2\ufe0f Collect Image URLs": {
      "main": [
        [
          {
            "node": "\ud83d\udcbe Guardar sesi\u00f3n Supabase (Carousel)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udcbe Guardar sesi\u00f3n Supabase (Carousel)": {
      "main": [
        [
          {
            "node": "\ud83d\udd17 Re-attach carousel data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd17 Re-attach carousel data": {
      "main": [
        [
          {
            "node": "\ud83d\udce8 Split URLs WA",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udce8 Split URLs WA": {
      "main": [
        [
          {
            "node": "\ud83d\udce4 Enviar imagen WA",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udce4 Enviar imagen WA": {
      "main": [
        [
          {
            "node": "\ud83d\udcf1 Preparar mensaje WA",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83e\udd16 GPT-4o \u2014 Texto": {
      "main": [
        [
          {
            "node": "\ud83d\udd27 Parsear contenido",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd27 Parsear contenido": {
      "main": [
        [
          {
            "node": "\u2702\ufe0f Extract Hashtags (Single)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u2702\ufe0f Extract Hashtags (Single)": {
      "main": [
        [
          {
            "node": "\ud83d\uddbc\ufe0f \u00bfImagen propia?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\uddbc\ufe0f \u00bfImagen propia?": {
      "main": [
        [
          {
            "node": "\ud83d\udcce Imagen propia",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83c\udfa8 \u00bfIdeogram?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udcce Imagen propia": {
      "main": [
        [
          {
            "node": "\ud83d\udcf1 Preparar mensaje WA",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u26a1 Flux 2 Pro (FAL.AI)": {
      "main": [
        [
          {
            "node": "\ud83d\udd17 Normalizar URL imagen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd24 Ideogram v3": {
      "main": [
        [
          {
            "node": "\ud83d\udd17 Normalizar URL imagen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83c\udf4c Nano Banana Pro (FAL.AI)": {
      "main": [
        [
          {
            "node": "\ud83d\udd17 Normalizar URL imagen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd17 Normalizar URL imagen": {
      "main": [
        [
          {
            "node": "\ud83d\udcbe Guardar sesi\u00f3n Supabase",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udcbe Guardar sesi\u00f3n Supabase": {
      "main": [
        [
          {
            "node": "\ud83d\udd17 Re-attach session data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd17 Re-attach session data": {
      "main": [
        [
          {
            "node": "\ud83d\udce4 Enviar preview imagen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udcf1 Preparar mensaje WA": {
      "main": [
        [
          {
            "node": "\ud83d\udce4 Enviar WhatsApp",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udce8 Webhook \u2014 Reply WA": {
      "main": [
        [
          {
            "node": "\u2705 Responder YCloud",
            "type": "main",
            "index": 0
          },
          {
            "node": "\u2705 \u00bfAprobado?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u2705 \u00bfAprobado?": {
      "main": [
        [
          {
            "node": "\ud83d\udd0d Recuperar sesi\u00f3n Supabase",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\u274c Loguear rechazo",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd0d Recuperar sesi\u00f3n Supabase": {
      "main": [
        [
          {
            "node": "\ud83d\udd50 Compute wait_seconds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udce4 Enviar preview imagen": {
      "main": [
        [
          {
            "node": "\ud83d\udcf1 Preparar mensaje WA",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83c\udfa8 \u00bfIdeogram?": {
      "main": [
        [
          {
            "node": "\ud83d\udd24 Ideogram v3",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83c\udfa8 \u00bfNanoBanana?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83c\udfa8 \u00bfNanoBanana?": {
      "main": [
        [
          {
            "node": "\ud83c\udf4c Nano Banana Pro (FAL.AI)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\u26a1 Flux 2 Pro (FAL.AI)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd27 Prep Re-host Input": {
      "main": [
        [
          {
            "node": "\ud83d\udd01 Re-host Images",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd01 Re-host Images": {
      "main": [
        [
          {
            "node": "\ud83d\udd17 Merge Rehost Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd17 Merge Rehost Output": {
      "main": [
        [
          {
            "node": "\ud83d\udd00 \u00bfFormato Carrusel?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd00 \u00bfFormato Carrusel?": {
      "main": [
        [
          {
            "node": "\ud83c\udfa0 IG: Explode Carousel Slides",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83d\udce4 IG: Create Container",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83c\udfa0 IG: Explode Carousel Slides": {
      "main": [
        [
          {
            "node": "\ud83d\uddbc\ufe0f IG: Create Child Container",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\uddbc\ufe0f IG: Create Child Container": {
      "main": [
        [
          {
            "node": "\ud83d\uddc2\ufe0f IG: Collect Child IDs",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83c\udff7\ufe0f Tag IG Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\uddc2\ufe0f IG: Collect Child IDs": {
      "main": [
        [
          {
            "node": "\u23f3 IG: Wait 30s Carousel",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u23f3 IG: Wait 30s Carousel": {
      "main": [
        [
          {
            "node": "\ud83c\udfa0 IG: Create Parent Container",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83c\udfa0 IG: Create Parent Container": {
      "main": [
        [
          {
            "node": "\ud83d\ude80 IG: Carousel media_publish",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83c\udff7\ufe0f Tag IG Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\ude80 IG: Carousel media_publish": {
      "main": [
        [
          {
            "node": "\ud83d\udcac IG: Post Carousel Hashtag Comment",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83c\udff7\ufe0f Tag IG Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udcac IG: Post Carousel Hashtag Comment": {
      "main": [
        [
          {
            "node": "\ud83d\udd17 IG: Get Carousel Permalink",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83c\udff7\ufe0f Tag IG Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd17 IG: Get Carousel Permalink": {
      "main": [
        [
          {
            "node": "\ud83d\uddbc\ufe0f FB: Explode Carousel Slides",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83c\udff7\ufe0f Tag IG Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\uddbc\ufe0f FB: Explode Carousel Slides": {
      "main": [
        [
          {
            "node": "\ud83d\udce4 FB: Upload Photo Unpublished",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udce4 FB: Upload Photo Unpublished": {
      "main": [
        [
          {
            "node": "\ud83d\uddc2\ufe0f FB: Collect Photo IDs",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83c\udff7\ufe0f Tag FB Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\uddc2\ufe0f FB: Collect Photo IDs": {
      "main": [
        [
          {
            "node": "\ud83d\udd27 FB: Build attached_media",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd27 FB: Build attached_media": {
      "main": [
        [
          {
            "node": "\ud83c\udf10 FB: Publish Carousel Feed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83c\udf10 FB: Publish Carousel Feed": {
      "main": [
        [
          {
            "node": "\u2705 Notify WhatsApp Carousel",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83c\udff7\ufe0f Tag FB Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u2705 Notify WhatsApp Carousel": {
      "main": [
        [
          {
            "node": "\ud83d\udcca Google Sheets Log (Carousel)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udce4 IG: Create Container": {
      "main": [
        [
          {
            "node": "\u23f3 Wait 30s (container ready)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83c\udff7\ufe0f Tag IG Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u23f3 Wait 30s (container ready)": {
      "main": [
        [
          {
            "node": "\ud83d\ude80 IG: media_publish",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\ude80 IG: media_publish": {
      "main": [
        [
          {
            "node": "\ud83d\udcac IG: Post Hashtag Comment",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83c\udff7\ufe0f Tag IG Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udcac IG: Post Hashtag Comment": {
      "main": [
        [
          {
            "node": "\ud83d\udd17 IG: Get Permalink",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83c\udff7\ufe0f Tag IG Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd17 IG: Get Permalink": {
      "main": [
        [
          {
            "node": "\ud83c\udf10 FB: Publish Photo",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83c\udff7\ufe0f Tag IG Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u2705 Notify WhatsApp Success": {
      "main": [
        [
          {
            "node": "\ud83d\udcca Google Sheets Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83c\udf10 FB: Publish Photo": {
      "main": [
        [
          {
            "node": "\u2705 Notify WhatsApp Success",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83c\udff7\ufe0f Tag FB Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd50 Compute wait_seconds": {
      "main": [
        [
          {
            "node": "\u23f0 \u00bfProgramado?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u23f0 \u00bfProgramado?": {
      "main": [
        [
          {
            "node": "\u23f3 Wait \u2014 Scheduled Publish",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83d\udd27 Prep Re-host Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u23f3 Wait \u2014 Scheduled Publish": {
      "main": [
        [
          {
            "node": "\ud83d\udd27 Prep Re-host Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83c\udff7\ufe0f Tag IG Error": {
      "main": [
        [
          {
            "node": "\ud83d\udea8 Parse Meta Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83c\udff7\ufe0f Tag FB Error": {
      "main": [
        [
          {
            "node": "\ud83d\udea8 Parse Meta Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udea8 Parse Meta Error": {
      "main": [
        [
          {
            "node": "\u26a0\ufe0f \u00bfToken Expirado?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u26a0\ufe0f \u00bfToken Expirado?": {
      "main": [
        [
          {
            "node": "\ud83d\udce4 WA: Token Expirado",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83d\udce4 WA: Error Publicaci\u00f3n",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udce4 WA: Token Expirado": {
      "main": [
        [
          {
            "node": "\ud83d\udcca Sheets Fail Log",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83d\udcca Sheets Fail Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udce4 WA: Error Publicaci\u00f3n": {
      "main": [
        [
          {
            "node": "\ud83d\udcca Sheets Fail Log",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "\ud83d\udcca Sheets Fail Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udcca Sheets Fail Log": {
      "main": [
        [
          {
            "node": "\ud83e\uddf9 Extract Blob Names",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83e\uddf9 Extract Blob Names": {
      "main": [
        [
          {
            "node": "\ud83d\uddd1\ufe0f Delete Azure Blob",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udcca Google Sheets Log": {
      "main": [
        [
          {
            "node": "\ud83e\uddf9 Extract Blob Names",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udcca Google Sheets Log (Carousel)": {
      "main": [
        [
          {
            "node": "\ud83e\uddf9 Extract Blob Names",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "callerPolicy": "workflowsFromSameOwner",
    "availableInMCP": false,
    "binaryMode": "separate"
  }
}