/* |-------------------------------------------------------------------------- | Agência PSI — Edge Function: notification-webhook |-------------------------------------------------------------------------- | Recebe webhooks de status dos provedores (Evolution API, Meta, etc.) | e atualiza notification_logs + processa opt-out (SAIR). | | Runtime: Deno (Supabase Edge Functions) | Linguagem: JavaScript puro | | ── Schema-per-tenant ── | notification_logs / notification_preferences / patients vivem no schema | físico `tenant_` (SEM coluna tenant_id). Este webhook NÃO recebe | tenant_id na URL, então resolve o tenant assim: | - Meta: `value.metadata.phone_number_id` → resolveTenantByChannel | (channel_routing.sender_address). Cada `change` é resolvido pro seu tenant. | - Status sem identificador de canal (Evolution messages.update, ou Meta | quando o phone_number_id não casa): faz fan-out por listTenantSchemas, | procurando o log pelo provider_message_id no schema de cada tenant. |-------------------------------------------------------------------------- */ import { adminClient, resolveTenantByChannel, listTenantSchemas, } from '../_shared/tenant.ts' const EVOLUTION_API_KEY = Deno.env.get('EVOLUTION_API_KEY') || '' const META_VERIFY_TOKEN = Deno.env.get('META_VERIFY_TOKEN') || '' // Client service_role no public — usado pra globais (channel_routing, tenants) // e como base pra derivar os clients de schema de cada tenant. const admin = adminClient() Deno.serve(async (req) => { const url = new URL(req.url) const provider = url.searchParams.get('provider') || 'unknown' // Meta webhook verification (GET com challenge) if (req.method === 'GET' && provider === 'meta') { return handleMetaVerification(url) } if (req.method !== 'POST') { return jsonResponse({ error: 'Method not allowed' }, 405) } try { const body = await req.json() switch (provider) { case 'evolution': return await handleEvolutionWebhook(req, body) case 'meta': return await handleMetaWebhook(body) default: return jsonResponse({ error: `Provider "${provider}" não suportado` }, 400) } } catch (err) { console.error(`[webhook] Erro:`, err.message) return jsonResponse({ error: err.message }, 500) } }) // ── Evolution API Webhook ─────────────────────────────────── /** * Processa webhooks da Evolution API. * Eventos relevantes: * - messages.update: status de entrega (enviado, entregue, lido) * - messages.upsert: mensagem recebida (para detectar "SAIR") * * Nota: o webhook Evolution não traz identificador de canal/tenant aqui, então * tanto status quanto opt-out fazem fan-out por todos os schemas de tenant. */ async function handleEvolutionWebhook (req, body) { // Validação básica da API key const apiKey = req.headers.get('apikey') || '' if (EVOLUTION_API_KEY && apiKey !== EVOLUTION_API_KEY) { return jsonResponse({ error: 'Invalid API key' }, 401) } const event = body.event const instance = body.instance console.log(`[evolution] Evento: ${event}, Instância: ${instance}`) // ── Status de mensagem enviada ──── if (event === 'messages.update') { const key = body.data?.key const messageId = key?.id const status = body.data?.update?.status if (!messageId) { return jsonResponse({ ok: true, skipped: 'no messageId' }) } // Mapeia status numérico da Evolution // 1 = pendente, 2 = enviado ao servidor, 3 = entregue, 4 = lido, 5 = erro const statusMap = { 1: null, // pendente — não atualiza 2: 'sent', // enviado ao servidor WhatsApp 3: 'delivered', // entregue ao dispositivo 4: 'read', // lido 5: 'failed', // erro } const mappedStatus = statusMap[status] if (!mappedStatus) { return jsonResponse({ ok: true, skipped: `status ${status} ignorado` }) } // Sem identificador de canal: procura o log em cada schema de tenant. await updateLogStatusFanout(messageId, mappedStatus) return jsonResponse({ ok: true, status: mappedStatus }) } // ── Mensagem recebida (para opt-out) ──── if (event === 'messages.upsert') { const message = body.data?.message const text = ( message?.conversation || message?.extendedTextMessage?.text || '' ).trim().toUpperCase() const remoteJid = body.data?.key?.remoteJid || '' const phone = remoteJid.replace('@s.whatsapp.net', '').replace('@c.us', '') // Detecta opt-out if (['SAIR', 'PARAR', 'STOP', 'CANCELAR MENSAGENS'].includes(text)) { console.log(`[evolution] Opt-out detectado: ${phone}`) await handleOptOut(phone, instance) return jsonResponse({ ok: true, action: 'opt_out' }) } // Detecta confirmação (futuro: atualizar status da sessão) if (['OK', '✅', 'CONFIRMAR', 'CONFIRMO', 'SIM'].includes(text)) { console.log(`[evolution] Confirmação detectada: ${phone}`) // TODO: buscar sessão pendente mais próxima e confirmar return jsonResponse({ ok: true, action: 'confirmation_detected' }) } return jsonResponse({ ok: true, action: 'message_ignored' }) } return jsonResponse({ ok: true, event_ignored: event }) } // ── Meta WhatsApp Webhook ─────────────────────────────────── /** * Verificação de webhook da Meta (challenge handshake). */ function handleMetaVerification (url) { const mode = url.searchParams.get('hub.mode') const token = url.searchParams.get('hub.verify_token') const challenge = url.searchParams.get('hub.challenge') if (mode === 'subscribe' && token === META_VERIFY_TOKEN) { return new Response(challenge, { status: 200 }) } return jsonResponse({ error: 'Verification failed' }, 403) } /** * Processa webhooks da Meta WhatsApp Business API. * * Cada `change` traz `value.metadata.phone_number_id` (o canal Meta do tenant). * Resolvemos o tenant via channel_routing.sender_address pra obter o client do * schema correto. Se não resolver (canal não cadastrado), status cai no fan-out * por message_id; opt-out segue por telefone em todos os schemas. */ async function handleMetaWebhook (body) { const entries = body.entry || [] for (const entry of entries) { const changes = entry.changes || [] for (const change of changes) { const value = change.value || {} // Identifica o canal Meta (phone_number_id) → tenant/schema const phoneNumberId = value.metadata?.phone_number_id ? String(value.metadata.phone_number_id) : null const ref = phoneNumberId ? await resolveTenantByChannel(admin, { senderAddress: phoneNumberId }) : null const tdb = ref ? admin.schema(ref.schema) : null // ── Status de mensagem ──── if (value.statuses) { for (const st of value.statuses) { const messageId = st.id const status = st.status // sent, delivered, read, failed const errors = st.errors || [] if (messageId && status) { const mappedStatus = status === 'failed' ? 'failed' : status if (tdb) { await updateLogStatus(tdb, messageId, mappedStatus, errors[0]?.message) } else { // Canal não resolvido: procura o log em todos os schemas. await updateLogStatusFanout(messageId, mappedStatus, errors[0]?.message) } } } } // ── Mensagens recebidas (opt-out) ──── if (value.messages) { for (const msg of value.messages) { const text = (msg.text?.body || '').trim().toUpperCase() const phone = msg.from || '' if (['SAIR', 'PARAR', 'STOP', 'CANCELAR MENSAGENS'].includes(text)) { console.log(`[meta] Opt-out detectado: ${phone}`) // Se resolvemos o tenant, processa só nele; senão, fan-out. await handleOptOut(phone, null, ref ? [ref] : null) } // Botão de resposta rápida (quick reply) if (msg.type === 'button' || msg.type === 'interactive') { const payload = msg.button?.payload || msg.interactive?.button_reply?.id || '' console.log(`[meta] Button reply: ${payload} de ${phone}`) // TODO: processar confirmação/cancelamento via botão } } } } } return jsonResponse({ ok: true }) } // ── Helpers compartilhados ────────────────────────────────── /** Monta o patch de notification_logs a partir do status mapeado. */ function buildLogPatch (status, failureReason) { const now = new Date().toISOString() const updateData = { provider_status: status } switch (status) { case 'sent': updateData.status = 'sent' break case 'delivered': updateData.status = 'delivered' updateData.delivered_at = now break case 'read': updateData.status = 'read' updateData.read_at = now break case 'failed': updateData.status = 'failed' updateData.failed_at = now updateData.failure_reason = failureReason || 'Falha reportada pelo provedor' break } return updateData } /** * Atualiza o status no notification_logs (schema do tenant já resolvido) com * base no provider_message_id. Retorna a contagem afetada (null se erro). */ async function updateLogStatus (tdb, providerMessageId, status, failureReason) { const updateData = buildLogPatch(status, failureReason) const { data, error } = await tdb .from('notification_logs') .update(updateData) .eq('provider_message_id', providerMessageId) .select('id') if (error) { console.warn(`[updateLogStatus] Erro ao atualizar ${providerMessageId}:`, error.message) return null } return data?.length ?? 0 } /** * Fan-out: sem canal/tenant conhecido, procura o provider_message_id no * notification_logs de cada schema de tenant e atualiza onde encontrar. * Para no primeiro schema que afetar uma linha (message_id é único globalmente). * * TODO: provider_message_id não tem índice global; com muitos tenants este loop * fica O(n). Idealmente registrar (provider_message_id → tenant) num índice * global no envio (notification_logs/channel_routing) pra resolver em O(1). */ async function updateLogStatusFanout (providerMessageId, status, failureReason) { const updateData = buildLogPatch(status, failureReason) const tenants = await listTenantSchemas(admin) for (const t of tenants) { const tdb = admin.schema(t.schema) const { data, error } = await tdb .from('notification_logs') .update(updateData) .eq('provider_message_id', providerMessageId) .select('id') if (error) { console.warn(`[updateLogStatusFanout] erro no schema ${t.schema}:`, error.message) continue } if (data && data.length > 0) { return data.length } } console.warn(`[updateLogStatusFanout] message ${providerMessageId} não encontrado em nenhum tenant`) return 0 } /** * Processa opt-out: desativa WhatsApp para o paciente e cancela pendentes. * @param {string} phone - número de telefone (apenas dígitos) * @param {string|null} instanceName - nome da instância Evolution (legado, não usado pra resolver schema) * @param {Array|null} tenantsOverride - se informado, restringe a esses tenants; * senão faz fan-out por todos os schemas. */ async function handleOptOut (phone, instanceName, tenantsOverride = null) { // Normaliza telefone const cleanPhone = String(phone).replace(/\D/g, '') if (!cleanPhone) return const tenants = tenantsOverride ?? await listTenantSchemas(admin) let matched = 0 for (const t of tenants) { const tdb = admin.schema(t.schema) // Busca paciente(s) com esse telefone no schema deste tenant const { data: patients, error: patErr } = await tdb .from('patients') .select('id, owner_id, telefone') .or(`telefone.like.%${cleanPhone}%`) if (patErr) { console.warn(`[opt-out] erro buscando paciente no schema ${t.schema}:`, patErr.message) continue } if (!patients || patients.length === 0) continue for (const patient of patients) { matched++ // Atualiza preferência (o trigger cancela pendentes automaticamente) const { error } = await tdb .from('notification_preferences') .upsert({ owner_id: patient.owner_id, patient_id: patient.id, whatsapp_opt_in: false, lgpd_opt_out_date: new Date().toISOString(), lgpd_opt_out_reason: 'Paciente respondeu SAIR no WhatsApp', }, { onConflict: 'owner_id,patient_id', ignoreDuplicates: false, }) if (error) { console.error(`[opt-out] Erro ao salvar preferência para paciente ${patient.id} (schema ${t.schema}):`, error.message) } else { console.log(`[opt-out] WhatsApp desativado para paciente ${patient.id} (schema ${t.schema})`) } } } if (matched === 0) { console.warn(`[opt-out] Nenhum paciente encontrado para ${cleanPhone}`) } } /** * Helper para respostas JSON padronizadas. */ function jsonResponse (data, status = 200) { return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' }, }) }