/* |-------------------------------------------------------------------------- | Agência PSI — Edge Function: process-notification-queue |-------------------------------------------------------------------------- | Processa a notification_queue para channel = 'whatsapp' via Evolution API. | | Fluxo por item: | 1. Busca pendentes (channel='whatsapp', status='pendente', scheduled_at <= now) | 2. Marca como 'processando' (lock otimista) | 3. Busca credenciais Evolution API em notification_channels | 4. Resolve template: tenant → global fallback | 5. Renderiza variáveis {{var}} | 6. Envia via Evolution API (sendText) | 7. Atualiza queue + insere notification_logs | 8. Em erro: retry com backoff ou marca 'falhou' |-------------------------------------------------------------------------- */ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Methods': 'POST, OPTIONS', } // ── Template renderer ────────────────────────────────────────── function renderTemplate(template: string, variables: Record): string { if (!template) return '' return template.replace(/\{\{([\w.]+)\}\}/g, (_, key) => { const val = variables[key] return val !== undefined && val !== null ? String(val) : '' }) } // ── Evolution API sender ─────────────────────────────────────── interface EvolutionCredentials { api_url: string api_key: string instance_name: string } async function sendWhatsapp( credentials: EvolutionCredentials, recipient: string, message: string ): Promise<{ messageId?: string; status: string }> { const url = `${credentials.api_url}/message/sendText/${credentials.instance_name}` const res = await fetch(url, { method: 'POST', headers: { 'apikey': credentials.api_key, 'Content-Type': 'application/json', }, body: JSON.stringify({ number: recipient, text: message, }), }) const data = await res.json() if (!res.ok) { throw new Error(data?.message || `Evolution API error ${res.status}`) } return { messageId: data?.key?.id || data?.messageId || null, status: 'sent', } } // ── Main handler ─────────────────────────────────────────────── Deno.serve(async (req: Request) => { if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders }) } const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ) const now = new Date().toISOString() // 1. Busca itens pendentes de WhatsApp const { data: items, error: fetchErr } = await supabase .from('notification_queue') .select('*') .eq('channel', 'whatsapp') .eq('status', 'pendente') .lte('scheduled_at', now) .lt('attempts', 5) .order('scheduled_at', { ascending: true }) .limit(20) if (fetchErr) { return new Response( JSON.stringify({ error: fetchErr.message }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } if (!items?.length) { return new Response( JSON.stringify({ message: 'Nenhum WhatsApp na fila', processed: 0 }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } const results: Array<{ id: string; status: string; error?: string }> = [] // Cache de credenciais por owner para evitar queries repetidas const credentialsCache = new Map() for (const item of items) { // 2. Lock otimista const { error: lockErr } = await supabase .from('notification_queue') .update({ status: 'processando', attempts: item.attempts + 1 }) .eq('id', item.id) .eq('status', 'pendente') if (lockErr) { results.push({ id: item.id, status: 'skip', error: 'lock failed' }) continue } try { // 3. Busca credenciais Evolution API (com cache) let credentials = credentialsCache.get(item.owner_id) if (credentials === undefined) { const { data: channel } = await supabase .from('notification_channels') .select('credentials') .eq('owner_id', item.owner_id) .eq('channel', 'whatsapp') .eq('is_active', true) .is('deleted_at', null) .maybeSingle() // Fallback: busca por tenant_id if (!channel?.credentials && item.tenant_id) { const { data: tenantChannel } = await supabase .from('notification_channels') .select('credentials') .eq('tenant_id', item.tenant_id) .eq('channel', 'whatsapp') .eq('is_active', true) .is('deleted_at', null) .maybeSingle() credentials = tenantChannel?.credentials as EvolutionCredentials | null } else { credentials = channel?.credentials as EvolutionCredentials | null } credentialsCache.set(item.owner_id, credentials ?? null) } if (!credentials) { throw new Error('Canal WhatsApp não encontrado ou inativo para este owner') } // 4. Resolve template: tenant → global fallback let templateBody: string | null = null const { data: tenantTpl } = await supabase .from('notification_templates') .select('body_text, is_active') .eq('tenant_id', item.tenant_id) .eq('key', item.template_key) .eq('channel', 'whatsapp') .eq('is_active', true) .is('deleted_at', null) .maybeSingle() if (tenantTpl) { templateBody = tenantTpl.body_text } else { const { data: globalTpl } = await supabase .from('notification_templates') .select('body_text') .is('tenant_id', null) .eq('key', item.template_key) .eq('channel', 'whatsapp') .eq('is_default', true) .eq('is_active', true) .is('deleted_at', null) .maybeSingle() templateBody = globalTpl?.body_text || null } if (!templateBody) { throw new Error(`Template WhatsApp não encontrado: ${item.template_key}`) } // 5. Renderiza variáveis const vars = item.resolved_vars || {} const message = renderTemplate(templateBody, vars) // 6. Envia via Evolution API const sendResult = await sendWhatsapp(credentials, item.recipient_address, message) // 7. Sucesso await supabase .from('notification_queue') .update({ status: 'enviado', sent_at: new Date().toISOString(), provider_message_id: sendResult.messageId || null, }) .eq('id', item.id) await supabase.from('notification_logs').insert({ tenant_id: item.tenant_id, owner_id: item.owner_id, queue_id: item.id, agenda_evento_id: item.agenda_evento_id, patient_id: item.patient_id, channel: 'whatsapp', template_key: item.template_key, schedule_key: item.schedule_key, recipient_address: item.recipient_address, resolved_message: message, resolved_vars: vars, status: 'sent', provider: 'evolution_api', provider_message_id: sendResult.messageId || null, sent_at: new Date().toISOString(), }) results.push({ id: item.id, status: 'enviado' }) } catch (err) { // 8. Erro — retry com backoff const attempts = item.attempts + 1 const maxAttempts = item.max_attempts || 5 const isExhausted = attempts >= maxAttempts const retryMs = attempts * 2 * 60 * 1000 await supabase .from('notification_queue') .update({ status: isExhausted ? 'falhou' : 'pendente', last_error: err.message, next_retry_at: isExhausted ? null : new Date(Date.now() + retryMs).toISOString(), }) .eq('id', item.id) await supabase.from('notification_logs').insert({ tenant_id: item.tenant_id, owner_id: item.owner_id, queue_id: item.id, agenda_evento_id: item.agenda_evento_id, patient_id: item.patient_id, channel: 'whatsapp', template_key: item.template_key, schedule_key: item.schedule_key, recipient_address: item.recipient_address, status: 'failed', failure_reason: err.message, failed_at: new Date().toISOString(), }) results.push({ id: item.id, status: isExhausted ? 'falhou' : 'retry', error: err.message }) } } const sent = results.filter(r => r.status === 'enviado').length const failed = results.filter(r => r.status === 'falhou').length const retried = results.filter(r => r.status === 'retry').length return new Response( JSON.stringify({ processed: results.length, sent, failed, retried, details: results }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) })