{ "name": "FoodSnap - Unified (Food & Coach)", "nodes": [ { "parameters": { "httpMethod": "POST", "path": "wa/inbound", "options": {} }, "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ -680, -940 ], "id": "webhook-unified", "name": "Webhook (Whatsapp)" }, { "parameters": { "jsCode": "const body = $json.body ?? $json;\nconst data = body.data ?? {};\n\n// =========================\n// RemoteJid (prioridade s.whatsapp.net)\n// =========================\nconst remoteJid =\n data?.key?.remoteJid?.includes('@s.whatsapp.net')\n ? data.key.remoteJid\n : data?.key?.remoteJidAlt || '';\n\n// número limpo (E.164 sem +)\nconst number = remoteJid.replace(/\\D/g, '');\n\n// =========================\n// Message ID\n// =========================\nconst message_id = data?.key?.id || '';\n\n// =========================\n// Texto e Caption\n// =========================\n// Verifica conversation, extendedTextMessage (text) e imageMessage (caption)\nconst text =\n data?.message?.conversation ||\n data?.message?.extendedTextMessage?.text ||\n data?.message?.imageMessage?.caption ||\n '';\n\n// =========================\n// Imagem\n// =========================\nconst imageMessage =\n data?.message?.imageMessage ||\n data?.message?.extendedTextMessage?.contextInfo?.quotedMessage?.imageMessage ||\n null;\n\n// =========================\n// Return normalizado\n// =========================\nreturn [\n {\n number,\n remoteJid,\n message_id,\n text,\n hasImage: !!imageMessage,\n imageMessage,\n pushName: data?.pushName || 'Atleta',\n timestamp: new Date().toISOString(),\n raw: body\n }\n];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -460, -940 ], "id": "normalize-inbound", "name": "Normalizar Dados" }, { "parameters": { "conditions": { "options": { "caseSensitive": false, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "check-coach", "leftValue": "={{ $json.text }}", "rightValue": "coach,treino,shape,biotipo,fisico,musculo", "operator": { "type": "string", "operation": "contains", "singleValue": true } } ], "combinator": "or" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ -240, -940 ], "id": "router-intent", "name": "É Coach?" }, { "parameters": { "operation": "executeQuery", "query": "with u as (\n select id\n from public.profiles\n where phone_e164 = cast({{ $('Normalizar Dados').item.json.number }} as text)\n limit 1\n),\nent as (\n select ue.is_active, ue.entitlement_code, ue.valid_until\n from public.user_entitlements ue\n where ue.user_id = (select id from u)\n order by ue.valid_until desc nulls last\n limit 1\n),\nusage as (\n select count(*) as used_count\n from public.coach_analyses fa\n where fa.user_id = (select id from u)\n and fa.used_free_quota = true\n)\nselect\n (select id from u) as user_id,\n (select id from u) is not null as exists,\n coalesce((select used_count from usage), 0)::int as free_used,\n greatest(0, 3 - coalesce((select used_count from usage), 0))::int as free_remaining,\n coalesce((select is_active from ent), false) as plan_active,\n (coalesce((select is_active from ent), false) = true OR greatest(0, 3 - coalesce((select used_count from usage), 0)) > 0) as can_process", "options": {} }, "type": "n8n-nodes-base.postgres", "typeVersion": 2.6, "position": [ 20, -1160 ], "id": "validate-coach", "name": "Validação Coach", "credentials": { "postgres": { "id": "2JDD2OJz4cAsWb0J", "name": "foodsnap_supabase" } } }, { "parameters": { "operation": "executeQuery", "query": "with u as (\n select id\n from public.profiles\n where phone_e164 = cast({{ $('Normalizar Dados').item.json.number }} as text)\n limit 1\n),\nent as (\n select\n ue.user_id,\n ue.is_active,\n ue.entitlement_code\n from public.user_entitlements ue\n where ue.user_id = (select id from u)\n order by ue.valid_until desc nulls last\n limit 1\n),\nusage as (\n select count(*) filter (where fa.used_free_quota = true) as free_used\n from public.food_analyses fa\n where fa.user_id = (select id from u)\n)\nselect\n (select id from u) is not null as exists,\n (select id from u) as user_id,\n coalesce((select free_used from usage), 0)::int as free_used,\n greatest(0, 5 - coalesce((select free_used from usage), 0))::int as free_remaining,\n coalesce((select is_active from ent), false) as plan_active,\n (\n (select id from u) is not null\n and (\n coalesce((select is_active from ent), false) = true\n or greatest(0, 5 - coalesce((select free_used from usage), 0)) > 0\n )\n ) as can_process;", "options": {} }, "type": "n8n-nodes-base.postgres", "typeVersion": 2.6, "position": [ 20, -740 ], "id": "validate-food", "name": "Validação Food", "credentials": { "postgres": { "id": "2JDD2OJz4cAsWb0J", "name": "foodsnap_supabase" } } }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "check-process", "leftValue": "={{ $json.can_process }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 260, -1160 ], "id": "if-coach-quota", "name": "Coach OK?" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 3 }, "conditions": [ { "id": "check-process-food", "leftValue": "={{ $json.can_process }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.3, "position": [ 260, -740 ], "id": "if-food-quota", "name": "Food OK?" }, { "parameters": { "resource": "messages-api", "instanceName": "FoodSnap", "remoteJid": "={{ $('Normalizar Dados').item.json.number }}", "messageText": "🧐 *Coach AI*: Analisando seu biótipo e gerando seu treino... Aguarde!", "options_message": {} }, "type": "n8n-nodes-evolution-api.evolutionApi", "typeVersion": 1, "position": [ 500, -1260 ], "id": "msg-ack-coach", "name": "Ack Coach", "credentials": { "evolutionApi": { "id": "nGWBERcZoQWgdxgk", "name": "FoodSnap" } } }, { "parameters": { "resource": "chat-api", "operation": "get-media-base64", "instanceName": "FoodSnap", "messageId": "={{ $('Normalizar Dados').item.json.message_id }}" }, "type": "n8n-nodes-evolution-api.evolutionApi", "typeVersion": 1, "position": [ 720, -1260 ], "id": "get-image-coach", "name": "Baixar IMG Coach", "credentials": { "evolutionApi": { "id": "nGWBERcZoQWgdxgk", "name": "FoodSnap" } } }, { "parameters": { "operation": "toBinary", "sourceProperty": "data.base64", "options": {} }, "type": "n8n-nodes-base.convertToFile", "typeVersion": 1.1, "position": [ 940, -1260 ], "id": "convert-binary-coach", "name": "Binário Coach" }, { "parameters": { "resource": "image", "operation": "analyze", "modelId": { "__rl": true, "value": "models/gemini-1.5-flash", "mode": "list", "cachedResultName": "models/gemini-1.5-flash" }, "text": "=Você é um Treinador Físico de Elite e Nutricionista Esportivo.\nAnalise a imagem fornecida (foto de corpo inteiro/físico).\n\n1. Verifique se é uma foto de corpo humano válida para análise fitness. Se não, retorne \"valid_body\": false.\n2. Se for válida, estime:\n - Biótipo Predominante (Ectomorfo, Mesomorfo, Endomorfo)\n - Percentual de Gordura (BF% aproximado)\n - Nível de Massa Muscular (Baixo, Médio, Alto)\n3. Sugira:\n - Objetivo Principal (Cutting, Bulking, Manutenção)\n - Divisão de Treino Recomendada (ex: ABC, ABCDE, Upper/Lower)\n - Foco Dietético (ex: Carb Cycling, Alto Carbo, Keto)\n - Dica de Ouro (uma frase curta de impacto)\n\nResponda APENAS em JSON estrito (sem markdown):\n{\n \"valid_body\": true,\n \"biotype\": \"...\",\n \"estimated_body_fat\": 15,\n \"muscle_mass\": \"Médio\",\n \"goal\": \"Bulking\",\n \"workout\": \"...\",\n \"diet\": \"...\",\n \"tip\": \"...\"\n}", "inputType": "binary", "simplify": false, "options": {} }, "type": "@n8n/n8n-nodes-langchain.googleGemini", "typeVersion": 1, "position": [ 1160, -1260 ], "id": "gemini-coach", "name": "Gemini Coach", "credentials": { "googlePalmApi": { "id": "T2uIVBcjJ9h8BFCC", "name": "Backup APIKEY" } } }, { "parameters": { "jsCode": "const raw = $json?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';\nconst clean = raw.replace(/```json/g, '').replace(/```/g, '').trim();\nlet data = {};\ntry { \n data = JSON.parse(clean); \n} catch(e) {\n data = { valid_body: false, error: 'Failed to parse AI' };\n}\n\nreturn [{ ...data }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1380, -1260 ], "id": "parse-coach-json", "name": "Parse Coach JSON" }, { "parameters": { "operation": "executeQuery", "query": "insert into public.coach_analyses\n(\n user_id,\n source,\n image_url,\n ai_raw_response,\n ai_structured,\n biotype,\n estimated_body_fat,\n goal_suggestion,\n muscle_mass_level,\n used_free_quota\n)\nvalues\n(\n cast('{{ $(\"Validação Coach\").item.json.user_id }}' as uuid),\n 'whatsapp',\n null,\n '{{ JSON.stringify($json) }}',\n '{{ JSON.stringify($json) }}',\n '{{ $json.biotype }}',\n cast({{ $json.estimated_body_fat || 0 }} as numeric),\n '{{ $json.goal }}',\n '{{ $json.muscle_mass }}',\n CASE\n WHEN {{ $(\"Validação Coach\").item.json.plan_active }} = true THEN false\n ELSE true\n END\n)\nreturning id as analysis_id;", "options": {} }, "type": "n8n-nodes-base.postgres", "typeVersion": 2.6, "position": [ 1600, -1260 ], "id": "save-coach-db", "name": "Salvar Coach DB", "credentials": { "postgres": { "id": "2JDD2OJz4cAsWb0J", "name": "foodsnap_supabase" } } }, { "parameters": { "resource": "messages-api", "instanceName": "FoodSnap", "remoteJid": "={{ $('Normalizar Dados').item.json.number }}", "messageText": "=⚡ *Coach AI Report*\n\n🧬 *Biótipo*: {{$json.biotype}}\n⚖️ *BF Estimado*: ~{{$json.estimated_body_fat}}%\n💪 *Massa Muscular*: {{$json.muscle_mass}}\n\n🎯 *Foco*: {{$json.goal}}\n🏋️ *Treino*: {{$json.workout}}\n🥗 *Dieta*: {{$json.diet}}\n\n💡 *Dica*: {{$json.tip}}\n\n_Acesse o App para ver a ficha completa!_", "options_message": {} }, "type": "n8n-nodes-evolution-api.evolutionApi", "typeVersion": 1, "position": [ 1820, -1260 ], "id": "reply-coach", "name": "Responder Coach", "credentials": { "evolutionApi": { "id": "nGWBERcZoQWgdxgk", "name": "FoodSnap" } } }, { "parameters": { "resource": "messages-api", "instanceName": "FoodSnap", "remoteJid": "={{ $('Normalizar Dados').item.json.number }}", "messageText": "📸 Recebi sua foto! Analisando o prato... ⏳", "options_message": {} }, "type": "n8n-nodes-evolution-api.evolutionApi", "typeVersion": 1, "position": [ 500, -740 ], "id": "ack-food", "name": "Ack Food", "credentials": { "evolutionApi": { "id": "nGWBERcZoQWgdxgk", "name": "FoodSnap" } } }, { "parameters": { "resource": "chat-api", "operation": "get-media-base64", "instanceName": "FoodSnap", "messageId": "={{ $('Normalizar Dados').item.json.message_id }}" }, "type": "n8n-nodes-evolution-api.evolutionApi", "typeVersion": 1, "position": [ 720, -740 ], "id": "get-image-food", "name": "Baixar IMG Food", "credentials": { "evolutionApi": { "id": "nGWBERcZoQWgdxgk", "name": "FoodSnap" } } }, { "parameters": { "operation": "toBinary", "sourceProperty": "data.base64", "options": {} }, "type": "n8n-nodes-base.convertToFile", "typeVersion": 1.1, "position": [ 940, -740 ], "id": "convert-binary-food", "name": "Binário Food" }, { "parameters": { "resource": "image", "operation": "analyze", "modelId": { "__rl": true, "value": "models/gemini-1.5-flash", "mode": "list", "cachedResultName": "models/gemini-1.5-flash" }, "text": "=Você é um assistente nutricional... (Prompt Original de Comida)... Retorne SOMENTE JSON.", "inputType": "binary", "simplify": false, "options": {} }, "type": "@n8n/n8n-nodes-langchain.googleGemini", "typeVersion": 1, "position": [ 1160, -740 ], "id": "gemini-food", "name": "Gemini Food", "credentials": { "googlePalmApi": { "id": "T2uIVBcjJ9h8BFCC", "name": "Backup APIKEY" } } }, { "parameters": { "jsCode": "// Limpeza de JSON da Comida (Original)\nconst raw = $json?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';\n// ... lógica existente de parse do FoodSnap ...\nreturn [{\n items: [],\n total: { calories: 500, protein: 30 },\n tip: { text: \"Exemplo de análise de comida\" }\n}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1380, -740 ], "id": "parse-food", "name": "Parse Food", "notes": "Lógica completa de parse de comida aqui (resumida para o arquivo)" }, { "parameters": { "operation": "executeQuery", "query": "insert into public.food_analyses ... (SQL Original)", "options": {} }, "type": "n8n-nodes-base.postgres", "typeVersion": 2.6, "position": [ 1600, -740 ], "id": "save-food-db", "name": "Salvar Food DB", "credentials": { "postgres": { "id": "2JDD2OJz4cAsWb0J", "name": "foodsnap_supabase" } } }, { "parameters": { "resource": "messages-api", "instanceName": "FoodSnap", "remoteJid": "={{ $('Normalizar Dados').item.json.number }}", "messageText": "=🥗 *FoodSnap*: Calorias: {{$json.total.calories}} ... (Formato Original)", "options_message": {} }, "type": "n8n-nodes-evolution-api.evolutionApi", "typeVersion": 1, "position": [ 1820, -740 ], "id": "reply-food", "name": "Responder Food", "credentials": { "evolutionApi": { "id": "nGWBERcZoQWgdxgk", "name": "FoodSnap" } } }, { "parameters": { "resource": "messages-api", "instanceName": "FoodSnap", "remoteJid": "={{ $('Normalizar Dados').item.json.number }}", "messageText": "⚠️ Por favor, envie uma *imagem* para análise.", "options_message": {} }, "type": "n8n-nodes-evolution-api.evolutionApi", "typeVersion": 1, "position": [ 260, -500 ], "id": "msg-no-image", "name": "Sem Imagem", "credentials": { "evolutionApi": { "id": "nGWBERcZoQWgdxgk", "name": "FoodSnap" } } } ], "connections": { "Webhook (Whatsapp)": { "main": [ [ { "node": "Normalizar Dados", "type": "main", "index": 0 } ] ] }, "Normalizar Dados": { "main": [ [ { "node": "É Coach?", "type": "main", "index": 0 } ] ] }, "É Coach?": { "main": [ [ { "node": "Validação Coach", "type": "main", "index": 0 } ], [ { "node": "Validação Food", "type": "main", "index": 0 } ] ] }, "Validação Coach": { "main": [ [ { "node": "Coach OK?", "type": "main", "index": 0 } ] ] }, "Validação Food": { "main": [ [ { "node": "Food OK?", "type": "main", "index": 0 } ] ] }, "Coach OK?": { "main": [ [ { "node": "Ack Coach", "type": "main", "index": 0 } ] ] }, "Food OK?": { "main": [ [ { "node": "Ack Food", "type": "main", "index": 0 } ] ] }, "Ack Coach": { "main": [ [ { "node": "Baixar IMG Coach", "type": "main", "index": 0 } ] ] }, "Ack Food": { "main": [ [ { "node": "Baixar IMG Food", "type": "main", "index": 0 } ] ] }, "Baixar IMG Coach": { "main": [ [ { "node": "Binário Coach", "type": "main", "index": 0 } ] ] }, "Baixar IMG Food": { "main": [ [ { "node": "Binário Food", "type": "main", "index": 0 } ] ] }, "Binário Coach": { "main": [ [ { "node": "Gemini Coach", "type": "main", "index": 0 } ] ] }, "Binário Food": { "main": [ [ { "node": "Gemini Food", "type": "main", "index": 0 } ] ] }, "Gemini Coach": { "main": [ [ { "node": "Parse Coach JSON", "type": "main", "index": 0 } ] ] }, "Gemini Food": { "main": [ [ { "node": "Parse Food", "type": "main", "index": 0 } ] ] }, "Parse Coach JSON": { "main": [ [ { "node": "Salvar Coach DB", "type": "main", "index": 0 } ] ] }, "Parse Food": { "main": [ [ { "node": "Salvar Food DB", "type": "main", "index": 0 } ] ] }, "Salvar Coach DB": { "main": [ [ { "node": "Responder Coach", "type": "main", "index": 0 } ] ] }, "Salvar Food DB": { "main": [ [ { "node": "Responder Food", "type": "main", "index": 0 } ] ] } } }