From eb2ceab354e0f7caa7f7d9a6418ce420c7670b3c Mon Sep 17 00:00:00 2001 From: marciobever Date: Wed, 22 Apr 2026 16:33:20 -0300 Subject: [PATCH] fix(webhook): move Coach state update outside setTimeout + re-read state before Food Scan + simplify menu Root cause: the COACH_FRONT state was being saved inside setTimeout, creating a race condition where the next message arrives before state is persisted. Now state is saved synchronously before returning. Also added a safety guard that re-reads state from DB right before entering Food Scan flow, making it impossible for coach photos to be incorrectly processed as food. Menu simplified to two clear options: Analisar Prato / Coach Corporal. --- .../functions/meta-whatsapp-webhook/index.ts | 146 +++++++++--------- 1 file changed, 77 insertions(+), 69 deletions(-) diff --git a/supabase/functions/meta-whatsapp-webhook/index.ts b/supabase/functions/meta-whatsapp-webhook/index.ts index e17b8e4..f4d88f5 100644 --- a/supabase/functions/meta-whatsapp-webhook/index.ts +++ b/supabase/functions/meta-whatsapp-webhook/index.ts @@ -813,81 +813,79 @@ RETORNE estritamente 3 bullet points recomendando o que o paciente pode adiciona state === "IDLE" && (interactiveId === "action_coach" || (textMessage && /coach|treino|avalia[çc][aã]o/i.test(textMessage))) ) { - // Offload long-running task to background - setTimeout(async () => { - // Verificar quantas análises coach o usuário já fez - const { count: coachUsed } = await supabase - .from("coach_analyses") - .select("*", { count: "exact", head: true }) - .eq("user_id", userId); + // Verificações de limite/cooldown ANTES de mudar o state (blocking) + const { count: coachUsed } = await supabase + .from("coach_analyses") + .select("*", { count: "exact", head: true }) + .eq("user_id", userId); - const FREE_COACH_LIMIT = 1; + const FREE_COACH_LIMIT = 1; - // Se passou do limite gratuito, verifica se tem plano pago - if ((coachUsed || 0) >= FREE_COACH_LIMIT) { - const { data: entitlement } = await supabase - .from("user_entitlements") - .select("is_active, valid_until, entitlement_code") - .eq("user_id", userId) - .match({ is_active: true }) - .maybeSingle(); - - const isPaid = entitlement && - ['pro', 'mensal', 'trimestral', 'anual', 'trial', 'paid'].includes(entitlement.entitlement_code) && - (!entitlement.valid_until || new Date(entitlement.valid_until) > new Date()); - - if (!isPaid) { - const stripeUrl = await generateStripeCheckoutUrl(userId); - await sendWhatsAppInteractiveMessage( - remoteJid, - `🔒 *Você já usou sua avaliação gratuita do Coach IA.*\n\nAssine o Plano PRO por *R$14,99/mês* e tenha avaliações ilimitadas de biotipo, treino e dieta personalizados. 🚀`, - "⭐ Assinar PRO Agora", - stripeUrl - ); - return; - } - } - - // Verificar cooldown de 7 dias (apenas para usuários pagos com muitas análises) - const { data: lastAnalysis } = await supabase - .from("coach_analyses") - .select("created_at") + if ((coachUsed || 0) >= FREE_COACH_LIMIT) { + const { data: entitlement } = await supabase + .from("user_entitlements") + .select("is_active, valid_until, entitlement_code") .eq("user_id", userId) - .order("created_at", { ascending: false }) - .limit(1) + .match({ is_active: true }) .maybeSingle(); - if (lastAnalysis && lastAnalysis.created_at) { - const lastDate = new Date(lastAnalysis.created_at); - const now = new Date(); - const diffTime = Math.abs(now.getTime() - lastDate.getTime()); - const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000; + const isPaid = entitlement && + ['pro', 'mensal', 'trimestral', 'anual', 'trial', 'paid'].includes(entitlement.entitlement_code) && + (!entitlement.valid_until || new Date(entitlement.valid_until) > new Date()); - if (diffTime < sevenDaysInMs) { - const daysRemaining = Math.ceil((sevenDaysInMs - diffTime) / (1000 * 60 * 60 * 24)); - await sendWhatsAppMessage( - remoteJid, - `⏳ *Calma, atleta!* O corpo precisa de tempo para evoluir.\n\nSua última avaliação foi há menos de uma semana.\nVocê poderá fazer uma nova avaliação em *${daysRemaining} dia(s)*.\n\nFoque no plano atual! 💪` - ); - return; - } + if (!isPaid) { + const stripeUrl = await generateStripeCheckoutUrl(userId); + await sendWhatsAppInteractiveMessage( + remoteJid, + `🔒 *Você já usou sua avaliação gratuita do Coach IA.*\n\nAssine o Plano PRO por *R$14,99/mês* e tenha avaliações ilimitadas de biotipo, treino e dieta personalizados. 🚀`, + "⭐ Assinar PRO Agora", + stripeUrl + ); + return; } + } - await supabase - .from("whatsapp_conversations") - .update({ state: "COACH_FRONT", temp_data: {} }) - .eq("phone_number", senderNumber); + const { data: lastAnalysis } = await supabase + .from("coach_analyses") + .select("created_at") + .eq("user_id", userId) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); - // Mensagem 1: Introdução calorosa + if (lastAnalysis && lastAnalysis.created_at) { + const lastDate = new Date(lastAnalysis.created_at); + const now = new Date(); + const diffTime = Math.abs(now.getTime() - lastDate.getTime()); + const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000; + + if (diffTime < sevenDaysInMs) { + const daysRemaining = Math.ceil((sevenDaysInMs - diffTime) / (1000 * 60 * 60 * 24)); + await sendWhatsAppMessage( + remoteJid, + `⏳ *Calma, atleta!* O corpo precisa de tempo para evoluir.\n\nSua última avaliação foi há menos de uma semana.\nVocê poderá fazer uma nova avaliação em *${daysRemaining} dia(s)*.\n\nFoque no plano atual! 💪` + ); + return; + } + } + + // *** CRITICAL: Salvar state ANTES de retornar, fora do setTimeout *** + await supabase + .from("whatsapp_conversations") + .update({ state: "COACH_FRONT", temp_data: {} }) + .eq("phone_number", senderNumber); + + console.log(`[WH] State changed to COACH_FRONT for ${senderNumber}`); + + // Mensagens de instrução (fire-and-forget, não bloqueia a resposta Meta) + setTimeout(async () => { await sendWhatsAppMessage( remoteJid, - "🤖 *Coach IA Ativado!*\n\nVou analisar sua composição corporal e montar um protocolo *Titan 100% personalizado* de treino e dieta.\n\nO processo é super simples: você envia apenas *1 foto realista* e a IA processa o resto! ⚡" + "🤖 *Coach IA Ativado!*\n\nVou analisar sua composição corporal e montar um protocolo *Titan 100% personalizado* de treino e dieta.\n\nO processo é super simples: você envia apenas *1 foto* e a IA processa o resto! ⚡" ); - // Pequena pausa para não parecer robótico await new Promise(r => setTimeout(r, 1500)); - // Mensagem 2: Instrução da primeira foto await sendWhatsAppMessage( remoteJid, "📸 *ENVIE SUA FOTO*\n\nTire uma selfie no espelho ou foto de frente do seu corpo.\n\n✅ Boa iluminação\n✅ Sem camisa (homens) ou de Top/Regata (mulheres)\n✅ Mostrando do pescoço até a cintura/joelhos" @@ -1113,6 +1111,21 @@ RETORNE estritamente 3 bullet points recomendando o que o paciente pode adiciona } // ── 6. Food Scan Flow (IDLE) ──────────────────────────────── + // SAFETY: Re-read state from DB to prevent race conditions + const { data: freshConv } = await supabase + .from("whatsapp_conversations") + .select("state") + .eq("phone_number", senderNumber) + .order("updated_at", { ascending: false }) + .limit(1); + + const freshState = freshConv?.[0]?.state || "IDLE"; + + if (freshState !== "IDLE") { + console.warn(`[WH] SAFETY BLOCK: state is ${freshState}, NOT IDLE. Aborting Food Scan.`); + return; + } + if (state === "IDLE") { console.log(`[WH] Entering Food Scan flow. isImage=${isImage}`); @@ -1162,19 +1175,14 @@ RETORNE estritamente 3 bullet points recomendando o que o paciente pode adiciona await sendWhatsAppListMenu( remoteJid, "FoodSnap IA", - "Fala ai! Sou a FoodSnap, a IA projetada para revolucionar seu fisico.\n\nEscolha uma opcao no menu abaixo, ou se preferir, *mande direto a foto do que voce esta comendo* e eu calculo tudo na hora!", + "Ola! Sou a FoodSnap IA.\n\n*Envie a foto do seu prato* direto aqui que eu analiso na hora, ou escolha uma opcao no menu abaixo.", "Abrir Menu", [ { - title: "Scanners Diarios", + title: "O que posso fazer", rows: [ - { id: "action_help_photo", title: "Dicas de Fotografia", description: "Veja como tirar a foto perfeita" }, - ] - }, - { - title: "Especialistas", - rows: [ - { id: "action_coach", title: "Coach de Saude", description: "Gerar plano de treino individual" }, + { id: "action_help_photo", title: "Analisar Prato", description: "Envie foto da comida e vejo calorias" }, + { id: "action_coach", title: "Coach Corporal IA", description: "Analiso seu fisico e monto treino" }, ] } ]