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.
This commit is contained in:
marciobever 2026-04-22 16:33:20 -03:00
parent 55fe0308cd
commit eb2ceab354

View file

@ -813,81 +813,79 @@ RETORNE estritamente 3 bullet points recomendando o que o paciente pode adiciona
state === "IDLE" && state === "IDLE" &&
(interactiveId === "action_coach" || (textMessage && /coach|treino|avalia[çc][aã]o/i.test(textMessage))) (interactiveId === "action_coach" || (textMessage && /coach|treino|avalia[çc][aã]o/i.test(textMessage)))
) { ) {
// Offload long-running task to background // Verificações de limite/cooldown ANTES de mudar o state (blocking)
setTimeout(async () => { const { count: coachUsed } = await supabase
// Verificar quantas análises coach o usuário já fez .from("coach_analyses")
const { count: coachUsed } = await supabase .select("*", { count: "exact", head: true })
.from("coach_analyses") .eq("user_id", userId);
.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) {
if ((coachUsed || 0) >= FREE_COACH_LIMIT) { const { data: entitlement } = await supabase
const { data: entitlement } = await supabase .from("user_entitlements")
.from("user_entitlements") .select("is_active, valid_until, entitlement_code")
.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")
.eq("user_id", userId) .eq("user_id", userId)
.order("created_at", { ascending: false }) .match({ is_active: true })
.limit(1)
.maybeSingle(); .maybeSingle();
if (lastAnalysis && lastAnalysis.created_at) { const isPaid = entitlement &&
const lastDate = new Date(lastAnalysis.created_at); ['pro', 'mensal', 'trimestral', 'anual', 'trial', 'paid'].includes(entitlement.entitlement_code) &&
const now = new Date(); (!entitlement.valid_until || new Date(entitlement.valid_until) > new Date());
const diffTime = Math.abs(now.getTime() - lastDate.getTime());
const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000;
if (diffTime < sevenDaysInMs) { if (!isPaid) {
const daysRemaining = Math.ceil((sevenDaysInMs - diffTime) / (1000 * 60 * 60 * 24)); const stripeUrl = await generateStripeCheckoutUrl(userId);
await sendWhatsAppMessage( await sendWhatsAppInteractiveMessage(
remoteJid, 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! 💪` `🔒 *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",
return; stripeUrl
} );
return;
} }
}
await supabase const { data: lastAnalysis } = await supabase
.from("whatsapp_conversations") .from("coach_analyses")
.update({ state: "COACH_FRONT", temp_data: {} }) .select("created_at")
.eq("phone_number", senderNumber); .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( await sendWhatsAppMessage(
remoteJid, 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)); await new Promise(r => setTimeout(r, 1500));
// Mensagem 2: Instrução da primeira foto
await sendWhatsAppMessage( await sendWhatsAppMessage(
remoteJid, 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" "📸 *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) ──────────────────────────────── // ── 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") { if (state === "IDLE") {
console.log(`[WH] Entering Food Scan flow. isImage=${isImage}`); 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( await sendWhatsAppListMenu(
remoteJid, remoteJid,
"FoodSnap IA", "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", "Abrir Menu",
[ [
{ {
title: "Scanners Diarios", title: "O que posso fazer",
rows: [ rows: [
{ id: "action_help_photo", title: "Dicas de Fotografia", description: "Veja como tirar a foto perfeita" }, { 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" },
},
{
title: "Especialistas",
rows: [
{ id: "action_coach", title: "Coach de Saude", description: "Gerar plano de treino individual" },
] ]
} }
] ]